From baa9dadfaea7c01644b1c6b803a56eb2548153ae Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 16 Oct 2026 17:48:54 +0000 Subject: [PATCH 1/3] feat(python): expose embedded runtime host functions to agent tools --- .github/workflows/python.yml | 45 +- crates/bashkit-python/README.md | 81 ++ crates/bashkit-python/bashkit/_bashkit.pyi | 33 +- crates/bashkit-python/bashkit/deepagents.py | 52 +- crates/bashkit-python/bashkit/langchain.py | 26 + crates/bashkit-python/pyproject.toml | 2 +- crates/bashkit-python/src/async_bridge.rs | 142 +++ crates/bashkit-python/src/lib.rs | 845 ++++++++++++++---- .../tests/_bashkit_categories.py | 9 +- .../tests/test_async_callbacks.py | 16 +- .../tests/test_error_handling.py | 2 +- .../tests/test_external_functions.py | 597 +++++++++++++ .../tests/test_teardown_determinism.py | 92 ++ .../test_typescript_external_functions.py | 132 +++ examples/runtime_external_functions.ipynb | 138 +++ site/src/content/apidocs/python.md | 26 +- 16 files changed, 2016 insertions(+), 222 deletions(-) create mode 100644 crates/bashkit-python/src/async_bridge.rs create mode 100644 crates/bashkit-python/tests/test_external_functions.py create mode 100644 crates/bashkit-python/tests/test_typescript_external_functions.py create mode 100644 examples/runtime_external_functions.ipynb diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 7f93145bc..d55d01e7d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -101,7 +101,8 @@ jobs: - name: Install wheel and test dependencies run: | pip install bashkit --no-index --find-links crates/bashkit-python/dist --force-reinstall - pip install pytest pytest-asyncio langchain-core langgraph fastapi httpx maturin + pip install pytest pytest-asyncio pytest-timeout langchain-core langgraph fastapi httpx maturin + pip install uvloop - name: Install Deep Agents integration dependencies if: matrix.python-version != '3.9' && matrix.python-version != '3.10' @@ -118,7 +119,45 @@ jobs: - name: Run tests working-directory: crates/bashkit-python - run: pytest tests/ -v + run: pytest tests/ -v --timeout=30 --timeout-method=thread + + # Loop ownership differs on Windows (Proactor) and Unix (Selector/uvloop). + # Test the native callback bridge on each platform instead of relying only + # on the Linux abi3 wheel matrix above. No random-FS fixture is needed here. + callback-platforms: + name: Callback loops (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Build and install native wheel + run: | + python -m pip install "maturin${{ env.MATURIN_VERSION }}" + python -m maturin build -m crates/bashkit-python/Cargo.toml --out dist + python -m pip install bashkit --no-index --find-links dist + python -m pip install pytest pytest-asyncio pytest-timeout langchain-core deepagents fastapi httpx + - name: Install uvloop on Unix + if: runner.os != 'Windows' + run: python -m pip install uvloop + - name: Test callback execution models + run: >- + python -m pytest --timeout=30 --timeout-method=thread + crates/bashkit-python/tests/test_external_functions.py + crates/bashkit-python/tests/test_typescript_external_functions.py + crates/bashkit-python/tests/test_registered_tools.py + crates/bashkit-python/tests/test_async_callbacks.py + crates/bashkit-python/tests/test_jupyter_compat.py + crates/bashkit-python/tests/test_teardown_determinism.py examples: name: Examples @@ -172,6 +211,8 @@ jobs: run: | jupyter nbconvert --to notebook --execute --ExecutePreprocessor.timeout=120 \ examples/jupyter_basics.ipynb --output /tmp/jupyter_basics_out.ipynb + jupyter nbconvert --to notebook --execute --ExecutePreprocessor.timeout=120 \ + examples/runtime_external_functions.ipynb --output /tmp/runtime_external_functions_out.ipynb # Verify wheel builds and passes twine check build-wheel: diff --git a/crates/bashkit-python/README.md b/crates/bashkit-python/README.md index 9095148d1..ea58ff4ec 100644 --- a/crates/bashkit-python/README.md +++ b/crates/bashkit-python/README.md @@ -437,6 +437,87 @@ result = tool.execute_sync("echo 'Hello from BashTool'") print(result.stdout) ``` +## Python Function Calls + +Register host functions for code running in embedded Python. Both `Bash` and +`BashTool` accept `external_functions` and an async `external_handler`: + +```python +from bashkit import BashTool + + +async def handler(name, args, kwargs): + if name == "lookup": + return {"value": args[0] * kwargs["multiplier"]} + raise ValueError(f"Unknown function: {name}") + + +tool = BashTool( + python=True, + env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, + external_functions=["lookup"], + external_handler=handler, +) +result = await tool.execute("python -c 'print(lookup(21, multiplier=2)[\"value\"])'") +assert result.stdout == "42\n" +``` + +LangChain's `create_bash_tool` / `BashkitTool` and deepagents' middleware/backend +accept the same options. `ainvoke()` and `BashkitBackend.aexecute()` run callbacks +on the caller's active asyncio loop, preserving loop-bound clients and request +ContextVars. Use these async APIs in FastAPI async endpoints or notebook cells +with top-level `await`. + +Synchronous execution works from ordinary threads, FastAPI sync endpoints, and +notebook cells with an already-running loop. It drives callbacks on a dedicated +private worker loop shared across synchronous calls to that instance. Clients +bound to another loop must use the async API. ContextVars are captured for each +execution; a shared instance serializes requests. Timeouts and cancellation +cancel that execution's pending callback tasks. + +If another handler is already waiting on the current loop, synchronous access +to that same interpreter fails immediately: blocking the loop would deadlock +the handler. Use the async API or move the synchronous operation to +`asyncio.to_thread()`. + +Callbacks are trusted host code: enforce service authorization and I/O limits +there. They cannot re-enter their own interpreter or live filesystem handles; +pass data through arguments/results or use another instance. Function names and +handlers survive `reset()` and in-place restore. Snapshot bytes do not serialize +callbacks: supply them again to `from_snapshot()` / `from_snapshot_keyed()`. + +Runnable notebook: [`runtime_external_functions.ipynb`](../../examples/runtime_external_functions.ipynb). + +### TypeScript Function Calls + +`BashTool` and its LangChain/deepagents adapters also accept separate +`typescript_external_functions` / `typescript_external_handler` options: + +```python +async def ts_handler(name, args): + return {"value": args[0] * args[1]["multiplier"]} + + +tool = BashTool( + typescript=True, + typescript_external_functions=["lookup"], + typescript_external_handler=ts_handler, +) +result = await tool.execute("ts -c 'const r = await lookup(21, {multiplier: 2}); r.value'") +assert result.stdout == "42\n" +``` + +TypeScript dispatch receives `(name, args)`; use an object argument for named +options. Values cross as `None`/null, booleans, signed 64-bit integers, finite +floats, strings, lists, and string-keyed dictionaries. `undefined` arguments map +to `None`; function arguments, unsupported return objects, non-finite numbers, +and nesting deeper than 64 levels are rejected. Python and TypeScript handlers +can coexist, share the VFS, and use the same thread/loop/reset/snapshot rules. +Native host handlers are unavailable in the Pyodide build. + +ZapCode currently loses `console.log()` output produced after an external call. +Return the final expression as shown above, or write results into the VFS. + ## Persistent Custom Builtins Use `custom_builtins={...}` on `Bash` or `BashTool` when you want diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index 9b49fb655..000047da6 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -395,7 +395,7 @@ class FileSystem: ... class ExternalHandler(Protocol): - """Protocol for the external function handler passed to Bash. + """Protocol for the external function handler passed to Bash or BashTool. Called when Monty Python code invokes a registered external function. Must be an async callable with this exact signature. @@ -415,6 +415,11 @@ class ExternalHandler(Protocol): async def __call__(self, fn_name: str, args: list[Any], kwargs: dict[str, Any]) -> Any: ... +class TypeScriptExternalHandler(Protocol): + """Async host handler for TypeScript calls; arguments and results are JSON values.""" + + async def __call__(self, fn_name: str, args: list[Any]) -> Any: ... + class ShellState: """Read-only snapshot of shell state. @@ -591,8 +596,8 @@ class Bash: def execute_sync(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: """Execute bash commands synchronously (blocking). - Not supported when ``external_handler`` is configured — use - ``execute()`` (async) instead. ``on_output`` must be synchronous. + Async external handlers use the instance's private worker loop. + Use ``await execute()`` for clients bound to the caller's running loop. ``on_output`` must be synchronous. Async ``custom_builtins`` callbacks run on a private loop here. When called from inside a running event loop (e.g. Jupyter / IPython), callbacks are dispatched to a background thread with their own loop so @@ -1116,6 +1121,10 @@ class BashTool: profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False, + external_functions: list[str] | None = None, + external_handler: ExternalHandler | None = None, + typescript_external_functions: list[str] | None = None, + typescript_external_handler: TypeScriptExternalHandler | None = None, ) -> None: """Create a new BashTool. @@ -1150,6 +1159,16 @@ class BashTool: profile: Execution profile (Standard or Compatibility). python: Register sandboxed Monty Python (default ``False``). typescript: Register sandboxed ZapCode TypeScript (default ``False``). + external_functions: Host function names callable from embedded Python. + external_handler: Async ``(name, args, kwargs)`` Python dispatcher. + typescript_external_functions: Host function names callable from TypeScript. + typescript_external_handler: Async ``(name, args)`` TypeScript dispatcher. + + External handlers run on the caller's loop with ``await execute()`` and + on a dedicated private worker loop with ``execute_sync()``, including + inside notebooks. ContextVars are captured per execution. Handlers must + not re-enter this interpreter or its live filesystem handles. Reset + retains callbacks; snapshot factories require callbacks supplied again. Embedded runtimes share the shell's virtual filesystem and stay enabled after ``reset()``. Python additionally requires the host to provide @@ -1431,6 +1450,10 @@ class BashTool: profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False, + external_functions: list[str] | None = None, + external_handler: ExternalHandler | None = None, + typescript_external_functions: list[str] | None = None, + typescript_external_handler: TypeScriptExternalHandler | None = None, ) -> BashTool: """Create a new ``BashTool`` from snapshot bytes and optional constructor kwargs.""" ... @@ -1456,6 +1479,10 @@ class BashTool: profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False, + external_functions: list[str] | None = None, + external_handler: ExternalHandler | None = None, + typescript_external_functions: list[str] | None = None, + typescript_external_handler: TypeScriptExternalHandler | None = None, ) -> BashTool: """Create a new ``BashTool`` from HMAC-protected snapshot bytes.""" ... diff --git a/crates/bashkit-python/bashkit/deepagents.py b/crates/bashkit-python/bashkit/deepagents.py index 241de71c6..bcf094261 100644 --- a/crates/bashkit-python/bashkit/deepagents.py +++ b/crates/bashkit-python/bashkit/deepagents.py @@ -38,6 +38,9 @@ from bashkit import BashTool as NativeBashTool +if TYPE_CHECKING: + from bashkit._bashkit import ExternalHandler, TypeScriptExternalHandler + # THREAT[TM-DOS-113]: Agent-facing VFS helpers bypass shell execution limits. # Keep every direct walk and retained grep result independently bounded. # @@ -102,7 +105,7 @@ def _lines(content: str): ) from deepagents.backends.utils import compile_grep_include_glob from langchain.agents.middleware.types import AgentMiddleware - from langchain_core.tools import tool as langchain_tool + from langchain_core.tools import StructuredTool DEEPAGENTS_AVAILABLE = True except ImportError: @@ -118,9 +121,7 @@ def _make_bash_tool(bash_instance: NativeBashTool, max_output_length: int = 100_ tool_name = bash_instance.name tool_description = bash_instance.description() - @langchain_tool(tool_name, description=tool_description) - def bashkit(command: str) -> str: - result = bash_instance.execute_sync(command) + def format_result(result) -> str: output = result.stdout if result.error: output += f"\nError: {result.error}" @@ -133,7 +134,15 @@ def bashkit(command: str) -> str: output = output[:max_output_length] + "\n[truncated]" return output - return bashkit + def bashkit(command: str) -> str: + return format_result(bash_instance.execute_sync(command)) + + async def abashkit(command: str) -> str: + return format_result(await bash_instance.execute(command)) + + # Decision: provide a native coroutine. LangChain's default async wrapper + # runs the sync function in a worker, losing caller-loop callback affinity. + return StructuredTool.from_function(func=bashkit, coroutine=abashkit, name=tool_name, description=tool_description) if DEEPAGENTS_AVAILABLE: @@ -162,6 +171,10 @@ def __init__( python: bool = False, typescript: bool = False, env: dict[str, str] | None = None, + external_functions: list[str] | None = None, + external_handler: ExternalHandler | None = None, + typescript_external_functions: list[str] | None = None, + typescript_external_handler: TypeScriptExternalHandler | None = None, ): """Initialize middleware. @@ -176,6 +189,14 @@ def __init__( typescript: Enable ZapCode TypeScript (ignored if bash_tool provided) env: Initial environment for a new tool (ignored if bash_tool provided). Python requires BASHKIT_ALLOW_INPROCESS_PYTHON=1 at construction. + external_functions: Python host function names (ignored if bash_tool provided). + external_handler: Async ``(name, args, kwargs)`` dispatcher + (ignored if bash_tool provided). Async tool calls use the + caller loop; sync calls use a private worker loop. + typescript_external_functions: TypeScript host function names + (ignored if bash_tool provided). + typescript_external_handler: Async ``(name, args)`` dispatcher + with JSON values (ignored if bash_tool provided). """ if bash_tool is not None: self._bash = bash_tool @@ -190,6 +211,10 @@ def __init__( python=python, typescript=typescript, env=env, + external_functions=external_functions, + external_handler=external_handler, + typescript_external_functions=typescript_external_functions, + typescript_external_handler=typescript_external_handler, ) self._owns_bash = True @@ -239,6 +264,10 @@ def __init__( python: bool = False, typescript: bool = False, env: dict[str, str] | None = None, + external_functions: list[str] | None = None, + external_handler: ExternalHandler | None = None, + typescript_external_functions: list[str] | None = None, + typescript_external_handler: TypeScriptExternalHandler | None = None, ): self._bash = NativeBashTool( username=username, @@ -249,6 +278,10 @@ def __init__( python=python, typescript=typescript, env=env, + external_functions=external_functions, + external_handler=external_handler, + typescript_external_functions=typescript_external_functions, + typescript_external_handler=typescript_external_handler, ) self._id = f"bashkit-{uuid.uuid4().hex[:8]}" self._operation_timeout = ( @@ -275,6 +308,15 @@ def create_middleware(self) -> BashkitMiddleware: # 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) + return self._execute_response(result) + + async def aexecute(self, command: str) -> ExecuteResponse: # type: ignore[override] + # Preserve caller-loop clients and ContextVars in runtime callbacks. + result = await self._bash.execute(command) + return self._execute_response(result) + + @staticmethod + def _execute_response(result) -> ExecuteResponse: output = result.stdout + (result.stderr or "") if result.error and result.error not in output: output += f"\nError: {result.error}" diff --git a/crates/bashkit-python/bashkit/langchain.py b/crates/bashkit-python/bashkit/langchain.py index 310ca6442..b03cf6fad 100644 --- a/crates/bashkit-python/bashkit/langchain.py +++ b/crates/bashkit-python/bashkit/langchain.py @@ -38,6 +38,9 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from bashkit._bashkit import ExternalHandler, TypeScriptExternalHandler + try: from langchain_core.tools import BaseTool, ToolException from pydantic import BaseModel, Field, PrivateAttr @@ -101,6 +104,10 @@ def __init__( allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, max_output_length: int = 100_000, + external_functions: list[str] | None = None, + external_handler: ExternalHandler | None = None, + typescript_external_functions: list[str] | None = None, + typescript_external_handler: TypeScriptExternalHandler | None = None, **kwargs, ): bash_tool = NativeBashTool( @@ -116,6 +123,10 @@ def __init__( mounts=mounts, allowed_mount_paths=allowed_mount_paths, readonly_filesystem=readonly_filesystem, + external_functions=external_functions, + external_handler=external_handler, + typescript_external_functions=typescript_external_functions, + typescript_external_handler=typescript_external_handler, ) kwargs["name"] = bash_tool.name kwargs["description"] = bash_tool.description() @@ -224,6 +235,10 @@ def create_bash_tool( allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, max_output_length: int = 100_000, + external_functions: list[str] | None = None, + external_handler: ExternalHandler | None = None, + typescript_external_functions: list[str] | None = None, + typescript_external_handler: TypeScriptExternalHandler | None = None, ) -> BashkitTool: """Create a LangChain-compatible Bashkit tool. @@ -245,6 +260,13 @@ def create_bash_tool( files and mounts are applied. max_output_length: Maximum number of characters returned to the LangChain agent from one bash tool call before truncation. + external_functions: Host function names callable from embedded Python. + external_handler: Async dispatcher receiving ``(name, args, kwargs)``. + With ``ainvoke`` it runs on the caller's loop; ``invoke`` uses a + private worker loop. Requires ``python=True`` and the host env grant. + typescript_external_functions: Host function names callable from TypeScript. + typescript_external_handler: Async ``(name, args)`` dispatcher with JSON + values and the same loop ownership rules. Requires ``typescript=True``. Returns: BashkitTool instance for use with LangChain agents @@ -276,6 +298,10 @@ def create_bash_tool( allowed_mount_paths=allowed_mount_paths, readonly_filesystem=readonly_filesystem, max_output_length=max_output_length, + external_functions=external_functions, + external_handler=external_handler, + typescript_external_functions=typescript_external_functions, + typescript_external_handler=typescript_external_handler, ) diff --git a/crates/bashkit-python/pyproject.toml b/crates/bashkit-python/pyproject.toml index 6a30012ef..1f721902a 100644 --- a/crates/bashkit-python/pyproject.toml +++ b/crates/bashkit-python/pyproject.toml @@ -37,7 +37,7 @@ Changelog = "https://github.com/everruns/bashkit/releases" langchain = ["langchain-core>=0.3", "langchain-anthropic>=0.3"] deepagents = ["deepagents>=0.7.13", "langchain-anthropic>=0.3"] pydantic-ai = ["pydantic-ai>=1.0"] -dev = ["pytest>=7.0", "pytest-asyncio>=0.23"] +dev = ["pytest>=7.0", "pytest-asyncio>=0.23", "pytest-timeout>=2.2"] [tool.maturin] manifest-path = "Cargo.toml" diff --git a/crates/bashkit-python/src/async_bridge.rs b/crates/bashkit-python/src/async_bridge.rs new file mode 100644 index 000000000..554a97fc8 --- /dev/null +++ b/crates/bashkit-python/src/async_bridge.rs @@ -0,0 +1,142 @@ +//! Drain native result deliveries before CPython destroys worker thread states. +//! +//! `call_soon_threadsafe` can wake the caller before its native invocation has +//! released all Python references. The caller can then exit while that worker +//! is still attached. Retain the upstream bridge's cancellation/context/panic +//! behavior, but track its blocking deliveries through their complete teardown. + +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Condvar, Mutex, Weak}; + +use pyo3::prelude::*; +use pyo3_async_runtimes::{TaskLocals, generic}; + +static DELIVERIES: (Mutex, Condvar) = (Mutex::new(0), Condvar::new()); +static PRIVATE_LOOPS: Mutex<(bool, Vec>)> = + Mutex::new((false, Vec::new())); + +pub(super) fn register_private_loop(private_loop: &Arc) { + let mut registry = PRIVATE_LOOPS.lock().expect("private loops registry lock"); + if registry.0 { + private_loop.closing.store(true, Ordering::Release); + } else { + registry.1.retain(|entry| entry.strong_count() > 0); + registry.1.push(Arc::downgrade(private_loop)); + } +} + +struct DeliveryGuard; + +impl Drop for DeliveryGuard { + fn drop(&mut self) { + let mut active = DELIVERIES.0.lock().expect("async deliveries lock"); + *active -= 1; + DELIVERIES.1.notify_all(); + } +} + +struct CallbackRuntime; + +impl generic::Runtime for CallbackRuntime { + type JoinError = tokio::task::JoinError; + type JoinHandle = tokio::task::JoinHandle<()>; + + fn spawn(future: F) -> Self::JoinHandle + where + F: Future + Send + 'static, + { + pyo3_async_runtimes::tokio::get_runtime().spawn(future) + } + + fn spawn_blocking(deliver: F) -> Self::JoinHandle + where + F: FnOnce() + Send + 'static, + { + let guard = { + let mut active = DELIVERIES.0.lock().expect("async deliveries lock"); + if super::interpreter_at_exit() { + None + } else { + *active += 1; + Some(DeliveryGuard) + } + }; + pyo3_async_runtimes::tokio::get_runtime().spawn_blocking(move || { + let Some(_guard) = guard else { + // Drop unattached: PyO3 defers decrefs rather than touching a + // finalizing interpreter. No Python call may begin after exit. + drop(deliver); + return; + }; + deliver(); + }) + } +} + +tokio::task_local! { + static LOCALS: TaskLocals; +} + +impl generic::ContextExt for CallbackRuntime { + fn scope(locals: TaskLocals, future: F) -> Pin + Send>> + where + F: Future + Send + 'static, + { + Box::pin(LOCALS.scope(locals, future)) + } + + fn get_task_locals() -> Option { + LOCALS.try_with(Clone::clone).ok() + } +} + +pub(super) fn future_into_py(py: Python<'_>, future: F) -> PyResult> +where + F: Future> + Send + 'static, + T: for<'py> IntoPyObject<'py> + Send + 'static, +{ + generic::future_into_py::(py, future) +} + +pub(super) fn get_current_locals(py: Python<'_>) -> PyResult { + generic::get_current_locals::(py) +} + +// THREAT[TM-PY-030]: hold the same enrollment lock while closing the boundary, +// then release the GIL so already-enrolled deliveries can finish their Python +// cleanup. Future deliveries cannot attach after the boundary is closed. +pub(super) fn shutdown(py: Python<'_>) { + // Private-loop callbacks may still be unwinding from timeout cancellation. + // Cancel and join them while Python remains fully attachable, then close + // the result-delivery boundary. New private loops cannot start meanwhile. + let private_loops = { + let mut registry = PRIVATE_LOOPS.lock().expect("private loops registry lock"); + registry.0 = true; + registry + .1 + .drain(..) + .filter_map(|entry| entry.upgrade()) + .collect::>() + }; + for private_loop in &private_loops { + private_loop.closing.store(true, Ordering::Release); + private_loop.cancel_inflight(); + } + for private_loop in private_loops { + private_loop.shutdown(); + } + let active = DELIVERIES.0.lock().expect("async deliveries lock"); + super::INTERPRETER_AT_EXIT.store(true, Ordering::Release); + drop(active); + py.detach(|| { + let active = DELIVERIES.0.lock().expect("async deliveries lock"); + drop( + DELIVERIES + .1 + .wait_while(active, |active| *active > 0) + .expect("async deliveries lock"), + ); + }); +} diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index ffa769568..43028df90 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -74,7 +74,9 @@ use pyo3::types::{ #[cfg(not(target_arch = "wasm32"))] use pyo3_async_runtimes::TaskLocals; #[cfg(not(target_arch = "wasm32"))] -use pyo3_async_runtimes::tokio::future_into_py; +mod async_bridge; +#[cfg(not(target_arch = "wasm32"))] +use async_bridge::future_into_py; // `caller_loop_locals` is a captured asyncio-loop handle, only ever `Some` when an // async `execute()` ran (native). On wasm there is no such loop, so the field is @@ -87,7 +89,7 @@ type CallerLoopLocals = std::convert::Infallible; use std::collections::{HashMap, HashSet}; use std::future::Future; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock, Weak}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::runtime::{Handle, Runtime}; @@ -231,7 +233,10 @@ fn interpreter_at_exit() -> bool { /// Registered with `atexit` at module import; not part of the public API. #[pyfunction] -fn _mark_interpreter_at_exit() { +fn _mark_interpreter_at_exit(_py: Python<'_>) { + #[cfg(not(target_arch = "wasm32"))] + async_bridge::shutdown(_py); + #[cfg(target_arch = "wasm32")] INTERPRETER_AT_EXIT.store(true, Ordering::Release); } @@ -1067,7 +1072,7 @@ enum FileSystemHandle { Static(Arc), Live { inner: Arc>, - external_handler_reentry_depth: Option>, + external_handler_reentry_guard: Option>, }, } @@ -1084,9 +1089,12 @@ impl FileSystemHandle { Self::Static(fs) => Ok(Arc::clone(fs)), Self::Live { inner, - external_handler_reentry_depth, + external_handler_reentry_guard, } => { - reject_external_handler_reentry_depth(external_handler_reentry_depth.as_ref())?; + reject_external_handler_reentry_guard( + external_handler_reentry_guard.as_ref(), + true, + )?; let bash = inner.lock().await; Ok(bash.fs()) } @@ -1094,31 +1102,65 @@ impl FileSystemHandle { } } -fn reject_external_handler_reentry_depth( - external_handler_reentry_depth: Option<&Arc>, +fn reject_external_handler_reentry_guard( + external_handler_reentry_guard: Option<&Arc>, + synchronous: bool, ) -> PyResult<()> { - if let Some(depth) = external_handler_reentry_depth - && depth.load(Ordering::Relaxed) > 0 - { - return Err(PyRuntimeError::new_err(EXTERNAL_HANDLER_REENTRY_ERROR)); + if let Some(guard) = external_handler_reentry_guard { + if !guard.enabled { + return Ok(()); + } + return Python::attach(|py| { + if guard.marker.call_method0(py, "get")?.extract::(py)? { + return Err(PyRuntimeError::new_err(EXTERNAL_HANDLER_REENTRY_ERROR)); + } + if synchronous { + let thread = guard.get_ident.call0(py)?; + let active = guard + .active_threads + .call_method1(py, "get", (thread, 0))? + .extract::(py)?; + if active > 0 { + return Err(PyRuntimeError::new_err( + "synchronous access would block the event loop running this interpreter's external handler; use await execute() or asyncio.to_thread()", + )); + } + } + Ok(()) + }); } Ok(()) } -struct ExternalHandlerReentryScope { - depth: Arc, +// Decision: reentry belongs to the callback's Python context, not the instance. +// An instance-wide counter also rejects unrelated requests while a callback +// awaits I/O. A ContextVar follows callbacks (and child tasks) across thread +// bridges while independent requests can still queue on the interpreter mutex. +// Sync calls on an active handler's own loop thread must fail fast, even from +// another task: blocking that loop would prevent the handler releasing the lock. +struct ExternalHandlerReentryGuard { + enabled: bool, + marker: Py, + wrap: Py, + active_threads: Py, + get_ident: Py, } -impl ExternalHandlerReentryScope { - fn enter(depth: Arc) -> Self { - depth.fetch_add(1, Ordering::Relaxed); - Self { depth } - } -} - -impl Drop for ExternalHandlerReentryScope { - fn drop(&mut self) { - self.depth.fetch_sub(1, Ordering::Relaxed); +impl ExternalHandlerReentryGuard { + fn new(py: Python<'_>, enabled: bool) -> PyResult> { + let module = PyModule::from_code( + py, + c"from contextvars import ContextVar\nfrom threading import get_ident\nmarker = ContextVar('bashkit_external_handler', default=False)\nactive_threads = {}\ndef wrap(marker, active_threads, handler):\n async def call(*args):\n thread = get_ident()\n active_threads[thread] = active_threads.get(thread, 0) + 1\n token = marker.set(True)\n try:\n return await handler(*args)\n finally:\n marker.reset(token)\n remaining = active_threads[thread] - 1\n if remaining:\n active_threads[thread] = remaining\n else:\n del active_threads[thread]\n return call\n", + c"", + c"_bashkit_external_handler", + )?; + Ok(Arc::new(Self { + enabled, + marker: module.getattr("marker")?.unbind(), + wrap: module.getattr("wrap")?.unbind(), + active_threads: module.getattr("active_threads")?.unbind(), + get_ident: module.getattr("get_ident")?.unbind(), + })) } } @@ -2026,25 +2068,15 @@ impl PyFileSystem { } } - fn from_live(inner: Arc>, rt: PyRuntime) -> Self { - Self { - inner: FileSystemHandle::Live { - inner, - external_handler_reentry_depth: None, - }, - rt, - } - } - fn from_live_with_reentry_guard( inner: Arc>, rt: PyRuntime, - external_handler_reentry_depth: Arc, + external_handler_reentry_guard: Arc, ) -> Self { Self { inner: FileSystemHandle::Live { inner, - external_handler_reentry_depth: Some(external_handler_reentry_depth), + external_handler_reentry_guard: Some(external_handler_reentry_guard), }, rt, } @@ -3022,9 +3054,53 @@ enum PySyncLoopMode { struct PrivateLoopWorkItem { awaitable: Py, context: Py, + call: Arc, result_tx: std::sync::mpsc::SyncSender>>, } +#[derive(Default)] +struct PrivateLoopCallState { + cancelled: AtomicBool, + task: StdMutex>>, +} + +// A timeout may drop the Rust future while its blocking worker still waits. +// Cancel this work item's Python task, never whichever task happens to be +// running next on the shared loop. The flag also covers cancellation before +// the worker publishes its task. +struct PrivateLoopCallGuard { + private_loop: Arc, + call: Arc, +} + +impl Drop for PrivateLoopCallGuard { + fn drop(&mut self) { + self.call.cancelled.store(true, Ordering::Release); + if interpreter_at_exit() { + return; + } + let Some(event_loop) = self.private_loop.event_loop.get() else { + return; + }; + Python::attach(|py| { + let task = self + .call + .task + .lock() + .expect("private call task slot") + .as_ref() + .map(|task| task.clone_ref(py)); + if let Some(task) = task + && let Ok(cancel) = task.bind(py).getattr("cancel") + { + let _ = event_loop + .bind(py) + .call_method1("call_soon_threadsafe", (cancel,)); + } + }); + } +} + // The lazily spawned worker behind a PyPrivateAsyncLoop: channel sender plus // the join handle used for deterministic teardown. struct PrivateLoopWorker { @@ -3057,13 +3133,16 @@ struct PyPrivateAsyncLoop { impl PyPrivateAsyncLoop { fn new() -> Arc { - Arc::new(Self { + let private_loop = Arc::new(Self { worker: StdMutex::new(None), event_loop: Arc::new(OnceLock::new()), current_task: Arc::new(StdMutex::new(None)), closing: Arc::new(AtomicBool::new(false)), bg_thread_runner: StdMutex::new(None), - }) + }); + #[cfg(not(target_arch = "wasm32"))] + async_bridge::register_private_loop(&private_loop); + private_loop } // Lazily start the dedicated worker thread and return a clone of its sender. @@ -3127,10 +3206,16 @@ impl PyPrivateAsyncLoop { )?; *current_task.lock().expect("private loop task slot") = Some(task.clone().unbind()); + *item.call.task.lock().expect("private call task slot") = + Some(task.clone().unbind()); + if item.call.cancelled.load(Ordering::Acquire) { + let _ = task.call_method0("cancel"); + } let result = event_loop .bind(py) .call_method1("run_until_complete", (task,)); *current_task.lock().expect("private loop task slot") = None; + *item.call.task.lock().expect("private call task slot") = None; result.map(|v| v.unbind()) }); // Ignore send errors: the caller timed out and moved on. @@ -3254,6 +3339,7 @@ def _run(coro, ctx): py: Python<'_>, awaitable: &Py, context: &Py, + call: Arc, ) -> PyResult> { // When a loop is already running on this thread (e.g. Jupyter / IPython), // asyncio forbids run_until_complete on any loop — even a brand-new one. @@ -3279,6 +3365,7 @@ def _run(coro, ctx): let item = PrivateLoopWorkItem { awaitable: awaitable.clone_ref(py), context: context.clone_ref(py), + call, result_tx, }; // THREAT[TM-PY-030]: send() is non-blocking (unbounded channel), so the @@ -3467,10 +3554,11 @@ impl PyCallbackSession { &self, py: Python<'_>, awaitable: &Py, + call: Arc, ) -> PyResult> { let context = self.current_context(py); self.private_async_loop - .run_awaitable(py, awaitable, &context) + .run_awaitable(py, awaitable, &context, call) } } @@ -3490,7 +3578,7 @@ fn capture_callback_state( // which is native-only — on wasm we always fall through to the private-loop path. #[cfg(not(target_arch = "wasm32"))] if needs_async_callbacks && use_caller_loop { - let locals = pyo3_async_runtimes::tokio::get_current_locals(py)?; + let locals = async_bridge::get_current_locals(py)?; return Ok(PyCallbackSessionState { context: locals.context(py).unbind(), caller_loop_locals: Some(locals), @@ -3860,8 +3948,9 @@ async fn call_python_callback_async( .map_err(|e| format!("{callback_name}: {e}"))? }; #[cfg(target_arch = "wasm32")] - let result = Python::attach(|py| session.run_awaitable_on_private_loop(py, &awaitable)) - .map_err(|e| format!("{callback_name}: {e}"))?; + let result = + Python::attach(|py| session.run_awaitable_on_private_loop(py, &awaitable, Arc::default())) + .map_err(|e| format!("{callback_name}: {e}"))?; Ok(result) } @@ -3871,11 +3960,16 @@ async fn run_private_loop_awaitable_yielding( session: Arc, awaitable: Py, ) -> PyResult> { + let call = Arc::new(PrivateLoopCallState::default()); + let _cancel_on_drop = PrivateLoopCallGuard { + private_loop: session.private_async_loop.clone(), + call: call.clone(), + }; // THREAT[TM-DOS-057]: execute_sync() has no caller asyncio loop. Run the // private Python event loop on Tokio's blocking pool so the interpreter // future yields and Bashkit's execution timeout can preempt slow callbacks. tokio::task::spawn_blocking(move || { - Python::attach(|py| session.run_awaitable_on_private_loop(py, &awaitable)) + Python::attach(|py| session.run_awaitable_on_private_loop(py, &awaitable, call)) }) .await .map_err(|e| PyRuntimeError::new_err(format!("Python async callback worker failed: {e}")))? @@ -4053,6 +4147,7 @@ fn capture_custom_builtin_session( builtins: &StdMutex>, engine: &Arc, use_caller_loop: bool, + has_external_handler: bool, ) -> PyResult>> { let (is_empty, has_async) = { let guard = builtins @@ -4060,13 +4155,13 @@ fn capture_custom_builtin_session( .map_err(|_| PyRuntimeError::new_err("custom_builtins lock poisoned"))?; (guard.is_empty(), guard.iter().any(|entry| entry.is_async)) }; - if is_empty { + if is_empty && !has_external_handler { return Ok(None); } PyCallbackSession::capture( py, engine.clone(), - has_async, + has_async || has_external_handler, use_caller_loop, PySyncLoopMode::SharedAcrossSessions, ) @@ -4283,63 +4378,278 @@ async fn exec_bash_with_optional_output( Ok(py_exec_result_from_bash_result(result)) } -/// Build a `PythonExternalFnHandler` from a Python async callable. -/// -/// The handler converts MontyObject args/kwargs to Python objects, calls the -/// async handler coroutine, awaits it, and converts the result back. -// Decision: reject same-instance live Bash access from external_handler. -// Releasing the interpreter mutex during Python callbacks would widen the -// execution model; a targeted guard keeps the failure explicit and local. -// Drives an async Python coroutine handler via pyo3-async-runtimes — native-only. -// On wasm, external_handler is rejected at construction. +// Decision: external functions share the custom-builtin callback session. The +// interpreter supplies a revocable request extension; no handler caches a +// caller loop or ContextVars across execute() calls. Sync calls reuse the +// instance's dedicated private-loop worker, including in notebooks. #[cfg(not(target_arch = "wasm32"))] -fn make_external_handler( - py_handler: Py, - external_handler_reentry_depth: Arc, -) -> PythonExternalFnHandler { +fn make_external_handler(py_handler: Py) -> PythonExternalFnHandler { Arc::new(move |fn_name, args, kwargs| { let py_handler = Python::attach(|py| py_handler.clone_ref(py)); - let external_handler_reentry_depth = external_handler_reentry_depth.clone(); Box::pin(async move { - let _reentry_scope = ExternalHandlerReentryScope::enter(external_handler_reentry_depth); - let fut = Python::attach(|py| { - let py_args = args - .iter() - .map(|o| monty_to_py(py, o)) - .collect::>>()?; - let py_args_list = PyList::new(py, &py_args)?; - let py_kwargs = PyDict::new(py); - for (k, v) in &kwargs { - py_kwargs.set_item(monty_to_py(py, k)?, monty_to_py(py, v)?)?; - } - let coro = py_handler.call1(py, (fn_name, py_args_list, py_kwargs))?; - pyo3_async_runtimes::tokio::into_future(coro.into_bound(py)) - }); - match fut { - Err(e) => ExtFunctionResult::Error(MontyException::new( + let result = async { + let session = bashkit::RuntimeCallContext::current() + .execution_extension::>() + .ok_or_else(|| "missing Python callback session".to_string())?; + let callback_session = session.try_with(Clone::clone).map_err(|e| e.to_string())?; + let py_args = Python::attach(|py| -> PyResult>> { + let positional = args + .iter() + .map(|o| monty_to_py(py, o)) + .collect::>>()?; + let keywords = PyDict::new(py); + for (key, value) in &kwargs { + keywords.set_item(monty_to_py(py, key)?, monty_to_py(py, value)?)?; + } + Ok(vec![ + fn_name.into_pyobject(py)?.into_any().unbind(), + PyList::new(py, positional)?.into_any().unbind(), + keywords.into_any().unbind(), + ]) + }) + .map_err(|e| e.to_string())?; + let value = session + .run(call_python_callback_async( + callback_session, + "external_handler", + &py_handler, + py_args, + )) + .await + .map_err(|e| e.to_string())??; + Python::attach(|py| py_to_monty(py, value.bind(py))).map_err(|e| e.to_string()) + } + .await; + match result { + Ok(value) => ExtFunctionResult::Return(value), + Err(message) => ExtFunctionResult::Error(MontyException::new( ExcType::RuntimeError, - Some(e.to_string()), + Some(message), )), - Ok(awaitable) => match awaitable.await { - Err(e) => ExtFunctionResult::Error(MontyException::new( - ExcType::RuntimeError, - Some(e.to_string()), - )), - Ok(py_result) => { - Python::attach(|py| match py_to_monty(py, py_result.bind(py)) { - Ok(v) => ExtFunctionResult::Return(v), - Err(e) => ExtFunctionResult::Error(MontyException::new( - ExcType::RuntimeError, - Some(e.to_string()), - )), - }) - } - }, } }) }) } +fn make_external_handler_native(py: Python<'_>, handler: &Py) -> PythonExternalFnHandler { + #[cfg(not(target_arch = "wasm32"))] + { + make_external_handler(handler.clone_ref(py)) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (py, handler); + unreachable!("external_handler rejected at construction on wasm") + } +} + +// TypeScript crosses the host boundary as JSON-shaped values. Convert directly +// with the same nesting cap as Monty; reject functions, non-finite numbers, +// arbitrary Python objects and out-of-range integers instead of stringifying +// them or silently losing precision. +#[cfg(not(target_arch = "wasm32"))] +fn zapcode_to_py( + py: Python<'_>, + value: &bashkit::ZapcodeValue, + depth: usize, +) -> PyResult> { + use bashkit::ZapcodeValue as V; + if depth > MAX_NESTING_DEPTH { + return Err(PyValueError::new_err( + "TypeScript value nesting depth exceeds maximum of 64", + )); + } + Ok(match value { + V::Undefined | V::Null => py.None(), + V::Bool(v) => v.into_pyobject(py)?.to_owned().into_any().unbind(), + V::Int(v) => v.into_pyobject(py)?.into_any().unbind(), + V::Float(v) if v.is_finite() => v.into_pyobject(py)?.into_any().unbind(), + V::String(v) => v.as_ref().into_pyobject(py)?.into_any().unbind(), + V::Array(values) => { + let values = values + .iter() + .map(|v| zapcode_to_py(py, v, depth + 1)) + .collect::>>()?; + PyList::new(py, values)?.into_any().unbind() + } + V::Object(values) => { + let dict = PyDict::new(py); + for (key, value) in values { + dict.set_item(key.as_ref(), zapcode_to_py(py, value, depth + 1)?)?; + } + dict.into_any().unbind() + } + _ => { + return Err(PyValueError::new_err( + "TypeScript external function arguments must be JSON values", + )); + } + }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn py_to_zapcode(obj: &Bound<'_, PyAny>, depth: usize) -> PyResult { + use bashkit::ZapcodeValue as V; + if depth > MAX_NESTING_DEPTH { + return Err(PyValueError::new_err( + "Python object nesting depth exceeds maximum of 64", + )); + } + if obj.is_none() { + return Ok(V::Null); + } + if let Ok(value) = obj.extract::() { + return Ok(V::Bool(value)); + } + if obj.is_instance_of::() { + return obj.extract::().map(V::Int).map_err(|_| { + PyValueError::new_err("TypeScript handler integers must fit in signed 64 bits") + }); + } + if let Ok(value) = obj.extract::() { + if !value.is_finite() { + return Err(PyValueError::new_err( + "TypeScript handler numbers must be finite", + )); + } + return Ok(V::Float(value)); + } + if let Ok(value) = obj.extract::() { + return Ok(V::String(Arc::from(value))); + } + if let Ok(values) = obj.cast::() { + return values + .iter() + .map(|v| py_to_zapcode(&v, depth + 1)) + .collect::>>() + .map(V::Array); + } + if let Ok(values) = obj.cast::() { + return values + .iter() + .map(|(key, value)| { + Ok(( + Arc::from(key.extract::()?), + py_to_zapcode(&value, depth + 1)?, + )) + }) + .collect::>() + .map(V::Object); + } + Err(PyTypeError::new_err( + "TypeScript external handler must return a JSON value", + )) +} + +fn make_typescript_external_handler_native( + py: Python<'_>, + handler: &Py, +) -> bashkit::TypeScriptExternalFnHandler { + #[cfg(not(target_arch = "wasm32"))] + { + let handler = handler.clone_ref(py); + Arc::new(move |name, args| { + let handler = Python::attach(|py| handler.clone_ref(py)); + Box::pin(async move { + let session = bashkit::RuntimeCallContext::current() + .execution_extension::>() + .ok_or_else(|| "missing Python callback session".to_string())?; + let callback_session = session.try_with(Clone::clone).map_err(|e| e.to_string())?; + let args = Python::attach(|py| -> PyResult>> { + Ok(vec![ + name.into_pyobject(py)?.into_any().unbind(), + PyList::new( + py, + args.iter() + .map(|value| zapcode_to_py(py, value, 0)) + .collect::>>()?, + )? + .into_any() + .unbind(), + ]) + }) + .map_err(|e| e.to_string())?; + let value = session + .run(call_python_callback_async( + callback_session, + "typescript_external_handler", + &handler, + args, + )) + .await + .map_err(|e| e.to_string())??; + Python::attach(|py| py_to_zapcode(value.bind(py), 0)).map_err(|e| e.to_string()) + }) + }) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (py, handler); + unreachable!("typescript_external_handler rejected at construction on wasm") + } +} + +fn prepare_external_handler( + py: Python<'_>, + python: bool, + names: &[String], + handler: Option>, + guard: &ExternalHandlerReentryGuard, +) -> PyResult>> { + prepare_runtime_external_handler(py, python, names, handler, guard, "python", "external") +} + +fn prepare_runtime_external_handler( + py: Python<'_>, + enabled: bool, + names: &[String], + handler: Option>, + guard: &ExternalHandlerReentryGuard, + runtime: &str, + prefix: &str, +) -> PyResult>> { + #[cfg(target_arch = "wasm32")] + if handler.is_some() { + return Err(PyRuntimeError::new_err(format!( + "{prefix}_handler is not available in the WebAssembly (Pyodide) build", + ))); + } + if !names.is_empty() && handler.is_none() { + return Err(PyValueError::new_err(format!( + "{prefix}_functions requires {prefix}_handler — the list has no effect without a handler", + ))); + } + let Some(handler) = handler else { + return Ok(None); + }; + if !enabled { + return Err(PyValueError::new_err(format!( + "{prefix}_handler requires {runtime}=True" + ))); + } + if !handler.bind(py).is_callable() { + return Err(PyValueError::new_err(format!( + "{prefix}_handler must be callable" + ))); + } + if !is_coroutine_callable(py, handler.bind(py))? { + return Err(PyValueError::new_err(format!( + "{prefix}_handler must be an async callable (coroutine function)" + ))); + } + guard + .wrap + .call1( + py, + ( + guard.marker.bind(py), + guard.active_threads.bind(py), + handler, + ), + ) + .map(Some) +} + /// Apply python/external_handler configuration to a `BashBuilder`. /// /// Centralises the logic shared between `new()` and `reset()`. @@ -4349,7 +4659,6 @@ fn apply_python_config( limits: PythonLimits, fn_names: Vec, handler: Option>, - external_handler_reentry_depth: Arc, ) -> bashkit::BashBuilder { // By construction, handler.is_some() implies python=true (validated in new()). // On wasm, external_handler is rejected at construction, so handler is always @@ -4357,11 +4666,8 @@ fn apply_python_config( match (python, handler) { #[cfg(not(target_arch = "wasm32"))] (true, Some(h)) => { - builder = builder.python_with_external_handler( - limits, - fn_names, - make_external_handler(h, external_handler_reentry_depth), - ); + builder = + builder.python_with_external_handler(limits, fn_names, make_external_handler(h)); // Passing python=True from Python is itself the explicit opt-in for // in-process Python execution; propagate that to the builtin's env gate. builder = builder.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1"); @@ -4369,7 +4675,7 @@ fn apply_python_config( #[cfg(target_arch = "wasm32")] (true, Some(_)) => unreachable!("external_handler rejected at construction on wasm"), (true, None) => { - let _ = (&fn_names, &external_handler_reentry_depth); + let _ = &fn_names; builder = builder.python_with_limits(limits); builder = builder.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1"); } @@ -4479,7 +4785,7 @@ pub struct PyBash { external_functions: Vec, /// Async Python callable invoked when Monty calls an external function. external_handler: Option>, - external_handler_reentry_depth: Arc, + external_handler_reentry_guard: Arc, /// Tracking list for `is_async` flag detection (used by session capture). /// Kept in sync with `host_registry` by `add_builtin` / `remove_builtin`. /// Wrapped in `Arc>` so post-construction registration works @@ -4526,7 +4832,11 @@ impl Drop for PyBash { impl PyBash { fn reject_external_handler_reentry(&self) -> PyResult<()> { - reject_external_handler_reentry_depth(Some(&self.external_handler_reentry_depth)) + reject_external_handler_reentry_guard(Some(&self.external_handler_reentry_guard), false) + } + + fn reject_external_handler_sync_access(&self) -> PyResult<()> { + reject_external_handler_reentry_guard(Some(&self.external_handler_reentry_guard), true) } fn build_live_builder(&self, py: Python<'_>) -> PyResult { @@ -4572,7 +4882,6 @@ impl PyBash { profile.python_limits().clone(), self.external_functions.clone(), handler_clone, - self.external_handler_reentry_depth.clone(), ); builder = apply_sqlite_config( builder, @@ -4688,47 +4997,22 @@ impl PyBash { let network = parse_network_config(network)?; let fn_names = external_functions.clone().unwrap_or_default(); - // external_handler runs an async Python coroutine driven through - // pyo3-async-runtimes, which the wasm build omits. Reject it there. - #[cfg(target_arch = "wasm32")] - if external_handler.is_some() { - return Err(PyRuntimeError::new_err( - "external_handler is not available in the WebAssembly (Pyodide) build", - )); - } - if !fn_names.is_empty() && external_handler.is_none() { - return Err(PyValueError::new_err( - "external_functions requires external_handler — the list has no effect without a handler", - )); - } - if external_handler.is_some() && !python { - return Err(PyValueError::new_err( - "external_handler requires python=True", - )); - } - if external_handler - .as_ref() - .is_some_and(|h| !h.bind(py).is_callable()) - { - return Err(PyValueError::new_err("external_handler must be callable")); - } - if let Some(ref handler) = external_handler { - let bound = handler.bind(py); - if !is_coroutine_callable(py, bound)? { - return Err(PyValueError::new_err( - "external_handler must be an async callable (coroutine function)", - )); - } - } + let external_handler_reentry_guard = + ExternalHandlerReentryGuard::new(py, external_handler.is_some())?; + let external_handler = prepare_external_handler( + py, + python, + &fn_names, + external_handler, + &external_handler_reentry_guard, + )?; let handler_for_build = external_handler.as_ref().map(|h| h.clone_ref(py)); - let external_handler_reentry_depth = Arc::new(AtomicUsize::new(0)); builder = apply_python_config( builder, python, core_profile.python_limits().clone(), fn_names, handler_for_build, - external_handler_reentry_depth.clone(), ); builder = apply_sqlite_config( builder, @@ -4773,7 +5057,7 @@ impl PyBash { sqlite, external_functions: external_functions.unwrap_or_default(), external_handler, - external_handler_reentry_depth, + external_handler_reentry_guard, custom_builtins: Arc::new(StdMutex::new(custom_builtins)), runtime_env: Arc::new(StdMutex::new(Vec::new())), runtime_mounts: Arc::new(StdMutex::new(Vec::new())), @@ -4832,8 +5116,13 @@ impl PyBash { on_output: Option>, ) -> PyResult> { self.reject_external_handler_reentry()?; - let builtin_session = - capture_custom_builtin_session(py, &self.custom_builtins, &self.builtin_engine, true)?; + let builtin_session = capture_custom_builtin_session( + py, + &self.custom_builtins, + &self.builtin_engine, + true, + self.external_handler.is_some(), + )?; let on_output = prepare_output_handler(py, on_output)?; let inner = self.inner.clone(); let cancel_session = builtin_session.clone(); @@ -4850,9 +5139,8 @@ impl PyBash { /// Execute commands synchronously (blocking). /// - /// Not supported when `external_handler` is configured: the handler is an async - /// Python coroutine that requires a running event loop, which is unavailable in - /// sync context. Use `execute()` (async) instead. + /// Async external handlers run on the instance's private worker loop. + /// Use `execute()` to use clients bound to the caller's active loop. /// /// Releases GIL before blocking on tokio to prevent deadlock with callbacks. /// @@ -4870,13 +5158,14 @@ impl PyBash { commands: String, on_output: Option>, ) -> PyResult { - if self.external_handler.is_some() { - return Err(PyRuntimeError::new_err( - "execute_sync is not supported when external_handler is configured — use execute() (async) instead, e.g. asyncio.run(bash.execute(...))", - )); - } - let builtin_session = - capture_custom_builtin_session(py, &self.custom_builtins, &self.builtin_engine, false)?; + self.reject_external_handler_sync_access()?; + let builtin_session = capture_custom_builtin_session( + py, + &self.custom_builtins, + &self.builtin_engine, + false, + self.external_handler.is_some(), + )?; let on_output = prepare_output_handler(py, on_output)?; let inner = self.inner.clone(); @@ -4904,8 +5193,6 @@ impl PyBash { } /// Execute commands synchronously. Raises `BashError` on non-zero exit. - /// - /// Not supported when `external_handler` is configured. #[pyo3(signature = (commands, on_output=None))] fn execute_sync_or_throw( &self, @@ -4932,8 +5219,13 @@ impl PyBash { on_output: Option>, ) -> PyResult> { self.reject_external_handler_reentry()?; - let builtin_session = - capture_custom_builtin_session(py, &self.custom_builtins, &self.builtin_engine, true)?; + let builtin_session = capture_custom_builtin_session( + py, + &self.custom_builtins, + &self.builtin_engine, + true, + self.external_handler.is_some(), + )?; let on_output = prepare_output_handler(py, on_output)?; let inner = self.inner.clone(); let cancel_session = builtin_session.clone(); @@ -4997,7 +5289,7 @@ impl PyBash { /// python mode, external function handler, and custom builtins. /// Releases GIL before blocking on tokio to prevent deadlock. fn reset(&self, py: Python<'_>) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; replace_live_bash_with_builder( py, &self.rt, @@ -5028,7 +5320,7 @@ impl PyBash { exclude_filesystem: bool, exclude_functions: bool, ) -> PyResult { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; commit_live_bash( py, &self.rt, @@ -5054,7 +5346,7 @@ impl PyBash { objects: HashMap>, policy: &str, ) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let state = capture_shell_state(py, &self.rt, &self.inner)?; let env_overrides = placeholder_env_overrides(&state, &self.network); checkout_live_bash( @@ -5073,7 +5365,7 @@ impl PyBash { /// Compare against `SnapshotGraph.capabilities(...)` to tell whether a /// stored commit will pass a given checkout policy before attempting it. fn capabilities(&self, py: Python<'_>) -> PyResult { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let rt = self.rt.clone(); let inner = self.inner.clone(); let caps = py.detach(|| { @@ -5093,7 +5385,7 @@ impl PyBash { exclude_filesystem: bool, exclude_functions: bool, ) -> PyResult> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let bytes = snapshot_live_bash( py, &self.rt, @@ -5113,7 +5405,7 @@ impl PyBash { exclude_filesystem: bool, exclude_functions: bool, ) -> PyResult> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let bytes = snapshot_live_bash_keyed( py, &self.rt, @@ -5136,19 +5428,19 @@ impl PyBash { /// `BashError` if the script does not parse; treat that as "deny or /// prompt", never as "no commands". fn analyze(&self, py: Python<'_>, script: &str) -> PyResult { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; analyze_script(py, &self.rt, &self.inner, script) } /// Capture a read-only shell-state snapshot for prompt rendering and inspection. fn shell_state(&self, py: Python<'_>) -> PyResult { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; capture_shell_state(py, &self.rt, &self.inner) } /// Restore interpreter state from bytes previously produced by `snapshot()`. fn restore_snapshot(&self, py: Python<'_>, data: Vec) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let state = capture_shell_state(py, &self.rt, &self.inner)?; let env_overrides = placeholder_env_overrides(&state, &self.network); restore_live_bash_with_env_overrides(py, &self.rt, &self.inner, data, &env_overrides) @@ -5156,7 +5448,7 @@ impl PyBash { /// Restore interpreter state from HMAC-protected bytes produced by `snapshot_keyed()`. fn restore_snapshot_keyed(&self, py: Python<'_>, data: Vec, key: Vec) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let state = capture_shell_state(py, &self.rt, &self.inner)?; let env_overrides = placeholder_env_overrides(&state, &self.network); restore_live_bash_keyed_with_env_overrides( @@ -5320,60 +5612,60 @@ impl PyBash { } fn read_file(&self, py: Python<'_>, path: String) -> PyResult { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| read_text_via_live_fs(&self.rt, &self.inner, path)) } fn write_file(&self, py: Python<'_>, path: String, content: String) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| write_text_via_live_fs(&self.rt, &self.inner, path, content)) } fn append_file(&self, py: Python<'_>, path: String, content: String) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| append_text_via_live_fs(&self.rt, &self.inner, path, content)) } #[pyo3(signature = (path, recursive=false))] fn mkdir(&self, py: Python<'_>, path: String, recursive: bool) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| mkdir_via_live_fs(&self.rt, &self.inner, path, recursive)) } fn exists(&self, py: Python<'_>, path: String) -> PyResult { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| exists_via_live_fs(&self.rt, &self.inner, path)) } #[pyo3(signature = (path, recursive=false))] fn remove(&self, py: Python<'_>, path: String, recursive: bool) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| remove_via_live_fs(&self.rt, &self.inner, path, recursive)) } fn stat(&self, py: Python<'_>, path: String) -> PyResult> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let metadata = py.detach(|| stat_via_live_fs(&self.rt, &self.inner, path))?; metadata_to_pydict(py, &metadata) } fn chmod(&self, py: Python<'_>, path: String, mode: u32) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| chmod_via_live_fs(&self.rt, &self.inner, path, mode)) } fn symlink(&self, py: Python<'_>, target: String, link: String) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| symlink_via_live_fs(&self.rt, &self.inner, target, link)) } fn read_link(&self, py: Python<'_>, path: String) -> PyResult { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; py.detach(|| read_link_via_live_fs(&self.rt, &self.inner, path)) } fn read_dir(&self, py: Python<'_>, path: String) -> PyResult> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let entries = py.detach(|| read_dir_via_live_fs(&self.rt, &self.inner, path))?; let items: Vec> = entries .iter() @@ -5384,7 +5676,7 @@ impl PyBash { #[pyo3(signature = (path=".".to_string()))] fn ls(&self, py: Python<'_>, path: String) -> PyResult> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let names = match py.detach(|| read_dir_via_live_fs(&self.rt, &self.inner, path)) { Ok(entries) => entries .into_iter() @@ -5396,7 +5688,7 @@ impl PyBash { } fn glob(&self, py: Python<'_>, pattern: String) -> PyResult> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let matches = py.detach(|| -> PyResult> { Ok(glob_via_bash( &self.rt, @@ -5415,13 +5707,13 @@ impl PyBash { /// batch reads where consistency isn't needed, prefer reading files via /// `execute_sync("cat ...")`. fn fs(&self, py: Python<'_>) -> PyResult> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; Py::new( py, PyFileSystem::from_live_with_reentry_guard( self.inner.clone(), self.rt.clone(), - self.external_handler_reentry_depth.clone(), + self.external_handler_reentry_guard.clone(), ), ) } @@ -5443,7 +5735,7 @@ impl PyBash { fs: PyRef<'_, PyFileSystem>, read_only: bool, ) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let inner = self.inner.clone(); let source = fs.inner.clone(); let runtime_mounts = self.runtime_mounts.clone(); @@ -5475,7 +5767,7 @@ impl PyBash { /// Unmount a live filesystem without rebuilding the interpreter. fn unmount(&self, py: Python<'_>, vfs_path: String) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let inner = self.inner.clone(); let runtime_mounts = self.runtime_mounts.clone(); py.detach(|| { @@ -5496,7 +5788,7 @@ impl PyBash { /// existing instance instead of only through the constructor. Survives /// `reset()` — like custom builtins, and unlike env a *script* exported. fn set_env(&self, py: Python<'_>, key: String, value: String) -> PyResult<()> { - self.reject_external_handler_reentry()?; + self.reject_external_handler_sync_access()?; let inner = self.inner.clone(); let runtime_env = self.runtime_env.clone(); py.detach(|| { @@ -5567,6 +5859,11 @@ pub struct BashTool { /// Opt-in runtimes, replayed for resets, snapshots, and tool metadata. python: bool, typescript: bool, + external_functions: Vec, + external_handler: Option>, + typescript_external_functions: Vec, + typescript_external_handler: Option>, + external_handler_reentry_guard: Arc, /// Tracking list for `is_async` flag detection (used by session capture). /// Kept in sync with `host_registry` by `add_builtin` / `remove_builtin`. /// Wrapped in `Arc>` so post-construction registration works @@ -5607,14 +5904,53 @@ impl Drop for BashTool { } impl BashTool { + fn reject_external_handler_reentry(&self) -> PyResult<()> { + reject_external_handler_reentry_guard(Some(&self.external_handler_reentry_guard), false) + } + + fn reject_external_handler_sync_access(&self) -> PyResult<()> { + reject_external_handler_reentry_guard(Some(&self.external_handler_reentry_guard), true) + } + + fn with_external_function_help(&self, mut text: String) -> String { + for (language, names) in [ + ("Python", &self.external_functions), + ("TypeScript", &self.typescript_external_functions), + ] { + if !names.is_empty() { + text.push_str(&format!( + "\n\n{language} host functions: {}. Use the host-provided signatures.", + names.join(", ") + )); + } + } + text + } + fn build_live_builder(&self, py: Python<'_>) -> PyResult { let profile = self.profile.core(); let mut builder = Bash::builder().profile(profile.clone()); if self.python { - builder = builder.python(); + if let Some(handler) = &self.external_handler { + builder = builder.python_with_external_handler( + profile.python_limits().clone(), + self.external_functions.clone(), + make_external_handler_native(py, handler), + ); + } else { + builder = builder.python(); + } } if self.typescript { - builder = builder.typescript(); + if let Some(handler) = &self.typescript_external_handler { + builder = builder.typescript_with_external_handler( + profile.typescript_limits().clone(), + self.typescript_external_functions.clone(), + make_typescript_external_handler_native(py, handler), + ); + } else { + builder = builder.typescript(); + } } if let Some(ref username) = self.username { @@ -5745,6 +6081,10 @@ impl BashTool { profile=PyExecutionProfile::Standard, python=false, typescript=false, + external_functions=None, + external_handler=None, + typescript_external_functions=None, + typescript_external_handler=None, ))] fn new( py: Python<'_>, @@ -5765,14 +6105,56 @@ impl BashTool { profile: PyExecutionProfile, python: bool, typescript: bool, + external_functions: Option>, + external_handler: Option>, + typescript_external_functions: Option>, + typescript_external_handler: Option>, ) -> PyResult { let core_profile = profile.core(); let mut builder = Bash::builder().profile(core_profile.clone()); + let fn_names = external_functions.unwrap_or_default(); + let external_handler_reentry_guard = ExternalHandlerReentryGuard::new( + py, + external_handler.is_some() || typescript_external_handler.is_some(), + )?; + let external_handler = prepare_external_handler( + py, + python, + &fn_names, + external_handler, + &external_handler_reentry_guard, + )?; if python { - builder = builder.python(); + if let Some(handler) = &external_handler { + builder = builder.python_with_external_handler( + core_profile.python_limits().clone(), + fn_names.clone(), + make_external_handler_native(py, handler), + ); + } else { + builder = builder.python(); + } } + let ts_fn_names = typescript_external_functions.unwrap_or_default(); + let typescript_external_handler = prepare_runtime_external_handler( + py, + typescript, + &ts_fn_names, + typescript_external_handler, + &external_handler_reentry_guard, + "typescript", + "typescript_external", + )?; if typescript { - builder = builder.typescript(); + if let Some(handler) = &typescript_external_handler { + builder = builder.typescript_with_external_handler( + core_profile.typescript_limits().clone(), + ts_fn_names.clone(), + make_typescript_external_handler_native(py, handler), + ); + } else { + builder = builder.typescript(); + } } if let Some(ref u) = username { @@ -5844,6 +6226,11 @@ impl BashTool { env, python, typescript, + external_functions: fn_names, + external_handler, + typescript_external_functions: ts_fn_names, + typescript_external_handler, + external_handler_reentry_guard, custom_builtins: Arc::new(StdMutex::new(custom_builtins)), runtime_env: Arc::new(StdMutex::new(Vec::new())), runtime_mounts: Arc::new(StdMutex::new(Vec::new())), @@ -5894,8 +6281,14 @@ impl BashTool { commands: String, on_output: Option>, ) -> PyResult> { - let builtin_session = - capture_custom_builtin_session(py, &self.custom_builtins, &self.builtin_engine, true)?; + self.reject_external_handler_reentry()?; + let builtin_session = capture_custom_builtin_session( + py, + &self.custom_builtins, + &self.builtin_engine, + true, + self.external_handler.is_some() || self.typescript_external_handler.is_some(), + )?; let on_output = prepare_output_handler(py, on_output)?; let inner = self.inner.clone(); let cancel_session = builtin_session.clone(); @@ -5923,8 +6316,14 @@ impl BashTool { commands: String, on_output: Option>, ) -> PyResult { - let builtin_session = - capture_custom_builtin_session(py, &self.custom_builtins, &self.builtin_engine, false)?; + self.reject_external_handler_sync_access()?; + let builtin_session = capture_custom_builtin_session( + py, + &self.custom_builtins, + &self.builtin_engine, + false, + self.external_handler.is_some() || self.typescript_external_handler.is_some(), + )?; let on_output = prepare_output_handler(py, on_output)?; let inner = self.inner.clone(); @@ -5976,8 +6375,14 @@ impl BashTool { commands: String, on_output: Option>, ) -> PyResult> { - let builtin_session = - capture_custom_builtin_session(py, &self.custom_builtins, &self.builtin_engine, true)?; + self.reject_external_handler_reentry()?; + let builtin_session = capture_custom_builtin_session( + py, + &self.custom_builtins, + &self.builtin_engine, + true, + self.external_handler.is_some() || self.typescript_external_handler.is_some(), + )?; let on_output = prepare_output_handler(py, on_output)?; let inner = self.inner.clone(); let cancel_session = builtin_session.clone(); @@ -6032,6 +6437,7 @@ impl BashTool { /// THREAT[TM-PY-028]: Rebuild with same config to preserve security limits /// and registered custom builtins. fn reset(&self, py: Python<'_>) -> PyResult<()> { + self.reject_external_handler_sync_access()?; replace_live_bash_with_builder( py, &self.rt, @@ -6051,6 +6457,7 @@ impl BashTool { exclude_filesystem: bool, exclude_functions: bool, ) -> PyResult> { + self.reject_external_handler_sync_access()?; let bytes = snapshot_live_bash( py, &self.rt, @@ -6070,6 +6477,7 @@ impl BashTool { exclude_filesystem: bool, exclude_functions: bool, ) -> PyResult> { + self.reject_external_handler_sync_access()?; let bytes = snapshot_live_bash_keyed( py, &self.rt, @@ -6092,16 +6500,19 @@ impl BashTool { /// `BashError` if the script does not parse; treat that as "deny or /// prompt", never as "no commands". fn analyze(&self, py: Python<'_>, script: &str) -> PyResult { + self.reject_external_handler_sync_access()?; analyze_script(py, &self.rt, &self.inner, script) } /// Capture a read-only shell-state snapshot for prompt rendering and inspection. fn shell_state(&self, py: Python<'_>) -> PyResult { + self.reject_external_handler_sync_access()?; capture_shell_state(py, &self.rt, &self.inner) } /// Restore interpreter state from bytes previously produced by `snapshot()`. fn restore_snapshot(&self, py: Python<'_>, data: Vec) -> PyResult<()> { + self.reject_external_handler_sync_access()?; let state = capture_shell_state(py, &self.rt, &self.inner)?; let env_overrides = placeholder_env_overrides(&state, &self.network); restore_live_bash_with_env_overrides(py, &self.rt, &self.inner, data, &env_overrides) @@ -6109,6 +6520,7 @@ impl BashTool { /// Restore interpreter state from HMAC-protected bytes produced by `snapshot_keyed()`. fn restore_snapshot_keyed(&self, py: Python<'_>, data: Vec, key: Vec) -> PyResult<()> { + self.reject_external_handler_sync_access()?; let state = capture_shell_state(py, &self.rt, &self.inner)?; let env_overrides = placeholder_env_overrides(&state, &self.network); restore_live_bash_keyed_with_env_overrides( @@ -6142,6 +6554,10 @@ impl BashTool { profile=PyExecutionProfile::Standard, python=false, typescript=false, + external_functions=None, + external_handler=None, + typescript_external_functions=None, + typescript_external_handler=None, ))] #[allow(clippy::too_many_arguments)] fn from_snapshot( @@ -6164,6 +6580,10 @@ impl BashTool { profile: PyExecutionProfile, python: bool, typescript: bool, + external_functions: Option>, + external_handler: Option>, + typescript_external_functions: Option>, + typescript_external_handler: Option>, ) -> PyResult { let tool = Self::new( py, @@ -6184,6 +6604,10 @@ impl BashTool { profile, python, typescript, + external_functions, + external_handler, + typescript_external_functions, + typescript_external_handler, )?; tool.restore_snapshot(py, data)?; Ok(tool) @@ -6211,6 +6635,10 @@ impl BashTool { profile=PyExecutionProfile::Standard, python=false, typescript=false, + external_functions=None, + external_handler=None, + typescript_external_functions=None, + typescript_external_handler=None, ))] #[allow(clippy::too_many_arguments)] fn from_snapshot_keyed( @@ -6234,6 +6662,10 @@ impl BashTool { profile: PyExecutionProfile, python: bool, typescript: bool, + external_functions: Option>, + external_handler: Option>, + typescript_external_functions: Option>, + typescript_external_handler: Option>, ) -> PyResult { let tool = Self::new( py, @@ -6254,55 +6686,70 @@ impl BashTool { profile, python, typescript, + external_functions, + external_handler, + typescript_external_functions, + typescript_external_handler, )?; tool.restore_snapshot_keyed(py, data, key)?; Ok(tool) } fn read_file(&self, py: Python<'_>, path: String) -> PyResult { + self.reject_external_handler_sync_access()?; py.detach(|| read_text_via_live_fs(&self.rt, &self.inner, path)) } fn write_file(&self, py: Python<'_>, path: String, content: String) -> PyResult<()> { + self.reject_external_handler_sync_access()?; py.detach(|| write_text_via_live_fs(&self.rt, &self.inner, path, content)) } fn append_file(&self, py: Python<'_>, path: String, content: String) -> PyResult<()> { + self.reject_external_handler_sync_access()?; py.detach(|| append_text_via_live_fs(&self.rt, &self.inner, path, content)) } #[pyo3(signature = (path, recursive=false))] fn mkdir(&self, py: Python<'_>, path: String, recursive: bool) -> PyResult<()> { + self.reject_external_handler_sync_access()?; py.detach(|| mkdir_via_live_fs(&self.rt, &self.inner, path, recursive)) } fn exists(&self, py: Python<'_>, path: String) -> PyResult { + self.reject_external_handler_sync_access()?; py.detach(|| exists_via_live_fs(&self.rt, &self.inner, path)) } #[pyo3(signature = (path, recursive=false))] fn remove(&self, py: Python<'_>, path: String, recursive: bool) -> PyResult<()> { + self.reject_external_handler_sync_access()?; py.detach(|| remove_via_live_fs(&self.rt, &self.inner, path, recursive)) } fn stat(&self, py: Python<'_>, path: String) -> PyResult> { + self.reject_external_handler_sync_access()?; let metadata = py.detach(|| stat_via_live_fs(&self.rt, &self.inner, path))?; metadata_to_pydict(py, &metadata) } fn chmod(&self, py: Python<'_>, path: String, mode: u32) -> PyResult<()> { + self.reject_external_handler_sync_access()?; py.detach(|| chmod_via_live_fs(&self.rt, &self.inner, path, mode)) } fn symlink(&self, py: Python<'_>, target: String, link: String) -> PyResult<()> { + self.reject_external_handler_sync_access()?; py.detach(|| symlink_via_live_fs(&self.rt, &self.inner, target, link)) } fn read_link(&self, py: Python<'_>, path: String) -> PyResult { + self.reject_external_handler_sync_access()?; py.detach(|| read_link_via_live_fs(&self.rt, &self.inner, path)) } fn read_dir(&self, py: Python<'_>, path: String) -> PyResult> { + self.reject_external_handler_sync_access()?; let entries = py.detach(|| read_dir_via_live_fs(&self.rt, &self.inner, path))?; let items: Vec> = entries .iter() @@ -6313,6 +6760,7 @@ impl BashTool { #[pyo3(signature = (path=".".to_string()))] fn ls(&self, py: Python<'_>, path: String) -> PyResult> { + self.reject_external_handler_sync_access()?; let names = match py.detach(|| read_dir_via_live_fs(&self.rt, &self.inner, path)) { Ok(entries) => entries .into_iter() @@ -6324,6 +6772,7 @@ impl BashTool { } fn glob(&self, py: Python<'_>, pattern: String) -> PyResult> { + self.reject_external_handler_sync_access()?; let matches = py.detach(|| -> PyResult> { Ok(glob_via_bash( &self.rt, @@ -6342,9 +6791,14 @@ impl BashTool { /// batch reads where consistency isn't needed, prefer reading files via /// `execute_sync("cat ...")`. fn fs(&self, py: Python<'_>) -> PyResult> { + self.reject_external_handler_sync_access()?; Py::new( py, - PyFileSystem::from_live(self.inner.clone(), self.rt.clone()), + PyFileSystem::from_live_with_reentry_guard( + self.inner.clone(), + self.rt.clone(), + self.external_handler_reentry_guard.clone(), + ), ) } @@ -6365,6 +6819,7 @@ impl BashTool { fs: PyRef<'_, PyFileSystem>, read_only: bool, ) -> PyResult<()> { + self.reject_external_handler_sync_access()?; let inner = self.inner.clone(); let source = fs.inner.clone(); let runtime_mounts = self.runtime_mounts.clone(); @@ -6398,6 +6853,7 @@ impl BashTool { /// /// See `Bash.set_env`. Survives `reset()`. fn set_env(&self, py: Python<'_>, key: String, value: String) -> PyResult<()> { + self.reject_external_handler_sync_access()?; let inner = self.inner.clone(); let runtime_env = self.runtime_env.clone(); py.detach(|| { @@ -6412,6 +6868,7 @@ impl BashTool { /// Unmount a live filesystem without rebuilding the interpreter. fn unmount(&self, py: Python<'_>, vfs_path: String) -> PyResult<()> { + self.reject_external_handler_sync_access()?; let inner = self.inner.clone(); let runtime_mounts = self.runtime_mounts.clone(); py.detach(|| { @@ -6436,15 +6893,15 @@ impl BashTool { } fn description(&self) -> PyResult { - Ok(self.build_rust_tool()?.description().to_string()) + Ok(self.with_external_function_help(self.build_rust_tool()?.description().to_string())) } fn help(&self) -> PyResult { - Ok(self.build_rust_tool()?.help()) + Ok(self.with_external_function_help(self.build_rust_tool()?.help())) } fn system_prompt(&self) -> PyResult { - Ok(self.build_rust_tool()?.system_prompt()) + Ok(self.with_external_function_help(self.build_rust_tool()?.system_prompt())) } fn input_schema(&self) -> PyResult { diff --git a/crates/bashkit-python/tests/_bashkit_categories.py b/crates/bashkit-python/tests/_bashkit_categories.py index e063fbf0d..2903b25fc 100644 --- a/crates/bashkit-python/tests/_bashkit_categories.py +++ b/crates/bashkit-python/tests/_bashkit_categories.py @@ -2013,15 +2013,16 @@ async def handler(fn_name, args, kwargs): Bash(python=False, external_functions=["foo"], external_handler=handler) -def test_bash_execute_sync_with_handler_raises(): - """execute_sync is not supported when external_handler is configured.""" +def test_bash_execute_sync_with_handler(): + """External handlers use the same private-loop fallback as custom builtins.""" async def handler(fn_name, args, kwargs): return "ok" bash = Bash(python=True, external_functions=["foo"], external_handler=handler) - with pytest.raises(RuntimeError, match="execute_sync"): - bash.execute_sync("python3 -c 'print(foo())'") + result = bash.execute_sync("python3 -c 'print(foo())'") + assert result.exit_code == 0, result.stderr + assert result.stdout == "ok\n" def test_bash_sync_handler_raises(): diff --git a/crates/bashkit-python/tests/test_async_callbacks.py b/crates/bashkit-python/tests/test_async_callbacks.py index 88d7dd685..74f7d5eeb 100644 --- a/crates/bashkit-python/tests/test_async_callbacks.py +++ b/crates/bashkit-python/tests/test_async_callbacks.py @@ -43,10 +43,16 @@ def test_async_callback_execute_sync_honors_timeout(): """execute_sync() timeout preempts slow async callbacks on private loop.""" callback_done = threading.Event() + callback_cancelled = threading.Event() async def slow(params, stdin=None): - await asyncio.sleep(0.25) - callback_done.set() + try: + await asyncio.sleep(0.25) + except asyncio.CancelledError: + callback_cancelled.set() + raise + finally: + callback_done.set() return "late\n" tool = ScriptedTool("api", timeout_seconds=0.05) @@ -59,10 +65,8 @@ async def slow(params, stdin=None): assert elapsed < 0.2 assert r.exit_code == 1 assert "timeout" in (r.stderr + (r.error or "")).lower() - # Wait until the abandoned private-loop worker finishes before proceeding, - # so interpreter state is clean. Avoids the fixed sleep(0.3) that was both - # slow and flaky (threading.Event gives exact synchronisation). - assert callback_done.wait(timeout=2.0), "slow callback did not finish within 2 s" + assert callback_done.wait(timeout=2.0), "slow callback did not settle within 2 s" + assert callback_cancelled.is_set(), "timed-out callback was not cancelled" def test_dealloc_during_inflight_callback_does_not_deadlock(): diff --git a/crates/bashkit-python/tests/test_error_handling.py b/crates/bashkit-python/tests/test_error_handling.py index f8dd0b230..3b760515d 100644 --- a/crates/bashkit-python/tests/test_error_handling.py +++ b/crates/bashkit-python/tests/test_error_handling.py @@ -35,7 +35,7 @@ "test_bash_external_functions_without_handler_raises", "test_bash_non_callable_handler_raises", "test_bash_external_handler_requires_python_true", - "test_bash_execute_sync_with_handler_raises", + "test_bash_execute_sync_with_handler", "test_bash_sync_handler_raises", ) diff --git a/crates/bashkit-python/tests/test_external_functions.py b/crates/bashkit-python/tests/test_external_functions.py new file mode 100644 index 000000000..091a31bec --- /dev/null +++ b/crates/bashkit-python/tests/test_external_functions.py @@ -0,0 +1,597 @@ +"""Host Python functions use execution-local callback loops and contexts. + +Decision: exercise sync, async, worker-thread and framework entry points with +real interpreters. A constructor-forwarding mock cannot detect loop affinity +errors, deadlocks or request-context leakage. +""" + +import asyncio +import contextvars +import gc +import subprocess +import sys +import textwrap +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass + +import pytest + +from bashkit import BashTool + +request_id = contextvars.ContextVar("external_request_id", default="missing") +PYTHON_ENV = {"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"} + + +@pytest.fixture(autouse=True) +def collect_runtimes(): + yield + gc.collect() + + +@dataclass(frozen=True) +class Runtime: + name: str + + @property + def script(self): + if self.name == "python": + return "python -c 'print(lookup(21, multiplier=2)[\"value\"])'" + return "ts -c 'const r = await lookup(21, {multiplier: 2}); r.value'" + + @property + def print_call(self): + if self.name == "python": + return "python -c 'print(lookup())'" + return "ts -c 'await lookup()'" + + @property + def unknown_script(self): + return "python -c 'unregistered()'" if self.name == "python" else "ts -c 'await unregistered()'" + + def options(self, handler): + if self.name == "python": + return dict(python=True, env=PYTHON_ENV, external_functions=["lookup"], external_handler=handler) + + async def dispatch(name, args): + return await handler(name, args[:1], args[1] if len(args) > 1 else {}) + + return dict(typescript=True, typescript_external_functions=["lookup"], typescript_external_handler=dispatch) + + def make_tool(self, handler, **kwargs): + return BashTool(**self.options(handler), **kwargs) + + +@pytest.fixture(params=["python", "typescript"]) +def runtime(request): + return Runtime(request.param) + + +async def lookup(name, args, kwargs): + assert name == "lookup" + await asyncio.sleep(0) + return {"value": args[0] * kwargs["multiplier"]} + + +def test_sync_external_functions(runtime): + tool = runtime.make_tool(lookup) + result = tool.execute_sync(runtime.script) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + + +@pytest.mark.asyncio +async def test_async_external_functions_use_caller_loop_and_context(runtime): + loop = asyncio.get_running_loop() + thread = threading.get_ident() + gate = loop.create_future() + + async def handler(name, args, kwargs): + assert asyncio.get_running_loop() is loop + assert threading.get_ident() == thread + assert request_id.get() == "caller" + return await gate + + tool = runtime.make_tool(handler) + token = request_id.set("caller") + try: + pending = tool.execute(runtime.script) + loop.call_soon(gate.set_result, {"value": 42}) + result = await asyncio.wait_for(pending, 5) + finally: + request_id.reset(token) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + + +def test_sync_callbacks_from_multiple_threads_preserve_private_loop(runtime): + seen = [] + + async def handler(name, args, kwargs): + seen.append((asyncio.get_running_loop(), threading.get_ident(), request_id.get())) + await asyncio.sleep(0) + return {"value": 42} + + tool = runtime.make_tool(handler) + + def run(index): + request_id.set(str(index)) + result = tool.execute_sync(runtime.script) + assert result.exit_code == 0, result.stderr + return result.stdout + + with ThreadPoolExecutor(max_workers=4) as workers: + assert list(workers.map(run, range(12))) == ["42\n"] * 12 + assert len({id(loop) for loop, _, _ in seen}) == 1 + assert len({thread for _, thread, _ in seen}) == 1 + assert {context for _, _, context in seen} == {str(i) for i in range(12)} + + +def test_tool_reused_across_event_loops_and_threads(runtime): + async def handler(name, args, kwargs): + assert request_id.get() == str(threading.get_ident()) + await asyncio.sleep(0) + return {"value": 42} + + tool = runtime.make_tool(handler) + + async def run(): + request_id.set(str(threading.get_ident())) + result = await asyncio.wait_for(tool.execute(runtime.script), 5) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + + for _ in range(2): + asyncio.run(run()) + with ThreadPoolExecutor(max_workers=3) as workers: + list(workers.map(lambda _: asyncio.run(run()), range(6))) + + +@pytest.mark.parametrize("loop_kind", ["selector", "uvloop"]) +def test_alternative_event_loop(runtime, loop_kind): + if loop_kind == "uvloop": + loop = pytest.importorskip("uvloop").new_event_loop() + else: + loop = asyncio.SelectorEventLoop() + + async def handler(name, args, kwargs): + assert asyncio.get_running_loop() is loop + assert request_id.get() == "alternate-loop" + await asyncio.sleep(0) + return {"value": 42} + + tool = runtime.make_tool(handler) + + async def run(): + token = request_id.set("alternate-loop") + try: + result = await asyncio.wait_for(tool.execute(runtime.script), 5) + assert result.stdout == "42\n", result.stderr + finally: + request_id.reset(token) + + try: + loop.run_until_complete(run()) + finally: + loop.close() + + +def test_sync_access_cannot_block_an_active_handler_loop(runtime): + # A separate process bounds native lock waits even if the calling Python + # loop deadlocks. Async requests must still be allowed to queue on it. + code = textwrap.dedent(""" + import asyncio + import sys + from bashkit import BashTool + + async def main(): + started, released = asyncio.Event(), asyncio.Event() + async def handler(*args): + started.set() + await released.wait() + return 42 + if sys.argv[1] == "python": + options = dict(python=True, env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, + external_functions=["lookup"], external_handler=handler) + script = "python -c 'lookup()'" + else: + options = dict(typescript=True, typescript_external_functions=["lookup"], + typescript_external_handler=handler) + script = "ts -c 'await lookup()'" + tool = BashTool(**options, files={"/tmp/state": "data"}) + fs = tool.fs() + active = tool.execute(script) + await asyncio.wait_for(started.wait(), 2) + queued = tool.execute("echo queued") + for operation in (lambda: tool.execute_sync("echo unsafe"), + lambda: tool.read_file("/tmp/state"), tool.snapshot, + lambda: fs.read_file("/tmp/state")): + try: + operation() + except RuntimeError as exc: + assert "would block" in str(exc), str(exc) + else: + raise AssertionError("synchronous access blocked an active handler loop") + released.set() + assert (await active).exit_code == 0 + assert (await queued).stdout == "queued\\n" + asyncio.run(main()) + """) + result = subprocess.run([sys.executable, "-c", code, runtime.name], capture_output=True, text=True, timeout=8) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.asyncio +async def test_sync_external_functions_inside_running_notebook_loop(runtime): + caller = asyncio.get_running_loop() + + async def handler(name, args, kwargs): + assert asyncio.get_running_loop() is not caller + assert request_id.get() == "cell" + await asyncio.sleep(0) + return {"value": 42} + + token = request_id.set("cell") + try: + result = runtime.make_tool(handler).execute_sync(runtime.script) + finally: + request_id.reset(token) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + + +@pytest.mark.parametrize("keyed", [False, True]) +def test_external_configuration_survives_reset_and_snapshot(runtime, keyed): + tool = runtime.make_tool(lookup) + tool.execute_sync("echo persisted > /tmp/state") + key = b"k" * 32 + snapshot = tool.snapshot_keyed(key) if keyed else tool.snapshot() + factory = BashTool.from_snapshot_keyed if keyed else BashTool.from_snapshot + args = (snapshot, key) if keyed else (snapshot,) + restored = factory(*args, **runtime.options(lookup)) + assert restored.read_file("/tmp/state") == "persisted\n" + for _ in range(2): + result = restored.execute_sync(runtime.script) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + restored.reset() + + +@pytest.mark.parametrize( + "options, message", + [ + ({"external_functions": ["lookup"]}, "external_handler"), + ({"external_handler": lookup}, "python=True"), + ({"python": True, "external_handler": 42}, "callable"), + ({"python": True, "external_handler": lambda *args: 42}, "async"), + ], +) +def test_invalid_external_configuration(options, message): + with pytest.raises(ValueError, match=message): + BashTool(**options) + + +def test_external_functions_preserve_host_python_gate(): + tool = BashTool(python=True, external_functions=["lookup"], external_handler=lookup) + result = tool.execute_sync("export BASHKIT_ALLOW_INPROCESS_PYTHON=1; python -c 'print(lookup())'") + assert result.exit_code != 0 + assert "in-process Python disabled" in result.stderr + + +@pytest.mark.asyncio +async def test_cancellation_cancels_only_this_execution_and_allows_reuse(runtime): + started, cancelled = asyncio.Event(), asyncio.Event() + + async def handler(name, args, kwargs): + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + tool = runtime.make_tool(handler) + pending = tool.execute(runtime.script) + await asyncio.wait_for(started.wait(), 5) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + await asyncio.wait_for(cancelled.wait(), 5) + result = await asyncio.wait_for(tool.execute("echo reusable"), 5) + assert result.stdout == "reusable\n" + + +@pytest.mark.asyncio +async def test_timeout_cancels_handler(runtime): + cancelled = asyncio.Event() + + async def handler(name, args, kwargs): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + tool = runtime.make_tool(handler, timeout_seconds=0.2) + result = await asyncio.wait_for(tool.execute(runtime.script), 5) + assert result.exit_code != 0 + await asyncio.wait_for(cancelled.wait(), 5) + assert (await tool.execute("echo reusable")).stdout == "reusable\n" + + +def test_sync_timeout_cancels_private_loop_callback_and_allows_reuse(runtime): + cancelled = threading.Event() + calls = 0 + + async def handler(name, args, kwargs): + nonlocal calls + calls += 1 + if calls == 1: + try: + await asyncio.Event().wait() + finally: + cancelled.set() + return {"value": 42} + + tool = runtime.make_tool(handler, timeout_seconds=0.2) + result = tool.execute_sync(runtime.script) + assert result.exit_code != 0 + assert cancelled.wait(2), "timed-out callback kept running on the private loop" + result = tool.execute_sync(runtime.script) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + + +@pytest.mark.asyncio +async def test_unrelated_calls_queue_while_handler_is_running(runtime): + started, released = asyncio.Event(), asyncio.Event() + + async def handler(name, args, kwargs): + started.set() + await released.wait() + return {"value": 42} + + tool = runtime.make_tool(handler) + first = tool.execute(runtime.script) + await asyncio.wait_for(started.wait(), 5) + second = tool.execute("echo queued") + released.set() + results = await asyncio.wait_for(asyncio.gather(first, second), 5) + assert [r.stdout for r in results] == ["42\n", "queued\n"] + + +@pytest.mark.asyncio +async def test_cancelling_queued_request_preserves_active_handler(runtime): + started, released = asyncio.Event(), asyncio.Event() + + async def handler(name, args, kwargs): + started.set() + await released.wait() + return {"value": 42} + + tool = runtime.make_tool(handler) + active = tool.execute(runtime.script) + await asyncio.wait_for(started.wait(), 5) + queued = tool.execute("echo must-not-run > /tmp/cancelled-request") + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + released.set() + result = await asyncio.wait_for(active, 5) + assert result.stdout == "42\n", result.stderr + assert not tool.exists("/tmp/cancelled-request") + + +@pytest.mark.asyncio +async def test_cancelling_active_request_preserves_queued_callback(runtime): + started, cancelled = asyncio.Event(), asyncio.Event() + + async def handler(name, args, kwargs): + if not args: + return "next-request" + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + tool = runtime.make_tool(handler) + active = tool.execute(runtime.script) + await asyncio.wait_for(started.wait(), 5) + queued = tool.execute(runtime.print_call) + active.cancel() + with pytest.raises(asyncio.CancelledError): + await active + await asyncio.wait_for(cancelled.wait(), 5) + result = await asyncio.wait_for(queued, 5) + assert result.stdout == "next-request\n", result.stderr + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "operation", + ["read_file", "fs_handle", "execute", "execute_sync", "reset", "snapshot", "child_task", "worker_thread"], +) +async def test_handler_cannot_reenter_own_interpreter(runtime, operation): + tool = None + handle = None + + async def handler(name, args, kwargs): + if operation == "read_file": + return tool.read_file("/tmp/state") + if operation == "fs_handle": + return handle.read_file("/tmp/state") + if operation == "execute": + return await tool.execute("echo reentry") + if operation == "execute_sync": + return tool.execute_sync("echo reentry") + if operation == "worker_thread": + return await asyncio.to_thread(tool.read_file, "/tmp/state") + if operation == "child_task": + + async def reenter(): + return await tool.execute("echo reentry") + + return await asyncio.create_task(reenter()) + return getattr(tool, operation)() + + tool = runtime.make_tool(handler, files={"/tmp/state": "data"}) + handle = tool.fs() + result = await asyncio.wait_for(tool.execute(runtime.script), 5) + assert result.exit_code != 0 + assert "cannot re-enter" in result.stderr + (result.error or "") + assert tool.read_file("/tmp/state") == "data" + tool = handle = None # Break the deliberately recursive callback closure. + + +def test_handler_exception_and_conversion_error(runtime): + async def handler(name, args, kwargs): + raise ValueError("lookup failed") + + result = runtime.make_tool(handler).execute_sync(runtime.script) + assert result.exit_code != 0 + + assert "lookup failed" in result.stderr + (result.error or "") + + async def invalid_result(name, args, kwargs): + return object() + + result = runtime.make_tool(invalid_result).execute_sync(runtime.script) + assert result.exit_code != 0 + + +@pytest.mark.asyncio +async def test_external_functions_and_custom_builtins_share_execution_context(runtime): + loop = asyncio.get_running_loop() + seen = [] + + async def handler(name, args, kwargs): + seen.append((request_id.get(), asyncio.get_running_loop() is loop)) + return {"value": 42} + + async def builtin(ctx): + seen.append((request_id.get(), asyncio.get_running_loop() is loop)) + return "builtin\n" + + tool = runtime.make_tool(handler, custom_builtins={"host-builtin": builtin}) + token = request_id.set("mixed") + try: + result = await tool.execute_or_throw("host-builtin; " + runtime.script) + finally: + request_id.reset(token) + assert result.stdout == "builtin\n42\n" + assert seen == [("mixed", True), ("mixed", True)] + + +def test_sync_or_throw_and_unknown_function(runtime): + from bashkit import BashError + + tool = runtime.make_tool(lookup) + assert tool.execute_sync_or_throw(runtime.script).stdout == "42\n" + with pytest.raises(BashError): + tool.execute_sync_or_throw(runtime.unknown_script) + + +@pytest.mark.asyncio +async def test_handler_can_call_another_interpreter(runtime): + other = runtime.make_tool(lookup) + + async def handler(name, args, kwargs): + result = await other.execute(runtime.script) + assert result.exit_code == 0, result.stderr + return {"value": int(result.stdout)} + + tool = runtime.make_tool(handler) + result = await asyncio.wait_for(tool.execute(runtime.script), 5) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + + +@pytest.mark.asyncio +async def test_langchain_sync_and_async_external_functions(runtime): + pytest.importorskip("langchain_core") + from bashkit.langchain import BashkitTool, create_bash_tool + + loop = asyncio.get_running_loop() + + async def handler(name, args, kwargs): + return {"value": 42 if asyncio.get_running_loop() is loop else 21} + + for factory in (BashkitTool, create_bash_tool): + tool = factory(**runtime.options(handler)) + assert await tool.ainvoke({"commands": runtime.script}) == "42\n" + assert await asyncio.to_thread(tool.invoke, {"commands": runtime.script}) == "21\n" + + +@pytest.mark.asyncio +async def test_deepagents_sync_and_async_external_functions(runtime): + pytest.importorskip("deepagents") + from bashkit.deepagents import BashkitBackend, BashkitMiddleware + + loop = asyncio.get_running_loop() + + async def handler(name, args, kwargs): + return {"value": 42 if asyncio.get_running_loop() is loop else 21} + + options = runtime.options(handler) + backend = BashkitBackend(**options) + assert (await backend.aexecute(runtime.script)).output == "42\n" + assert (await asyncio.to_thread(backend.execute, runtime.script)).output == "21\n" + for middleware in (BashkitMiddleware(**options), backend.create_middleware()): + assert await middleware.tools[0].ainvoke({"command": runtime.script}) == "42" + assert await asyncio.to_thread(middleware.tools[0].invoke, {"command": runtime.script}) == "21" + + +@pytest.mark.asyncio +async def test_fastapi_async_requests_share_tool_without_context_leakage(runtime): + fastapi = pytest.importorskip("fastapi") + httpx = pytest.importorskip("httpx") + app = fastapi.FastAPI() + expected_loop = asyncio.get_running_loop() + + async def handler(name, args, kwargs): + assert asyncio.get_running_loop() is expected_loop + before = request_id.get() + await asyncio.sleep(0.01) + assert request_id.get() == before + return before + + tool = runtime.make_tool(handler) + + @app.get("/request/{value}") + async def endpoint(value: str): + token = request_id.set(value) + try: + result = await tool.execute(runtime.print_call) + assert result.exit_code == 0, result.stderr + return {"request_id": result.stdout.strip()} + finally: + request_id.reset(token) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + responses = await asyncio.wait_for(asyncio.gather(*(client.get(f"/request/{i}") for i in range(8))), 10) + assert [r.json()["request_id"] for r in responses] == [str(i) for i in range(8)] + + +def test_fastapi_sync_endpoint_uses_private_loop_and_request_context(runtime): + fastapi = pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + async def handler(name, args, kwargs): + await asyncio.sleep(0) + return request_id.get() + + tool = runtime.make_tool(handler) + app = fastapi.FastAPI() + + @app.get("/request/{value}") + def endpoint(value: str): + token = request_id.set(value) + try: + result = tool.execute_sync(runtime.print_call) + assert result.exit_code == 0, result.stderr + return {"request_id": result.stdout.strip()} + finally: + request_id.reset(token) + + with TestClient(app) as client: + for value in ("first", "second"): + assert client.get(f"/request/{value}").json() == {"request_id": value} diff --git a/crates/bashkit-python/tests/test_teardown_determinism.py b/crates/bashkit-python/tests/test_teardown_determinism.py index 1c57395c0..80a5b3f6c 100644 --- a/crates/bashkit-python/tests/test_teardown_determinism.py +++ b/crates/bashkit-python/tests/test_teardown_determinism.py @@ -217,3 +217,95 @@ def test_interpreter_exit_does_not_crash(script): timeout=60, ) assert proc.returncode == 0, proc.stderr.decode() + + +@pytest.mark.parametrize("kind", ["Bash", "BashTool", "ScriptedTool"]) +def test_exit_waits_for_native_result_delivery_to_return(kind): + # Publishing a result wakes Python before call_soon_threadsafe() returns + # on the native worker. Force that window open: atexit must drain the + # delivery's Python references before CPython destroys its thread state. + script = """ +import atexit +import asyncio +import os +import sys +import threading +import time + +returned = threading.Event() +def check_delivery_finished(): + if not returned.is_set(): + os._exit(5) +atexit.register(check_delivery_finished) + +from bashkit import Bash, BashTool +from bashkit.scripted import ScriptedTool + +class SlowDeliveryLoop(asyncio.SelectorEventLoop): + def call_soon_threadsafe(self, callback, *args, context=None): + native = threading.current_thread() is not threading.main_thread() + if native: + returned.clear() + handle = super().call_soon_threadsafe(callback, *args, context=context) + if native: + time.sleep(0.2) + returned.set() + return handle + +loop = SlowDeliveryLoop() +asyncio.set_event_loop(loop) +tool = {"Bash": Bash, "BashTool": BashTool, "ScriptedTool": lambda: ScriptedTool("api")}[sys.argv[1]]() +async def run(): + assert (await tool.execute("echo ok")).stdout == "ok\\n" +loop.run_until_complete(run()) +loop.close() +""" + preamble = "import sys; sys.path[:0] = %r\n" % [p for p in sys.path if p] + proc = subprocess.run([sys.executable, "-c", preamble + script, kind], capture_output=True, timeout=10) + assert proc.returncode == 0, proc.stderr.decode() + + +@pytest.mark.parametrize("kind", ["builtin", "python", "typescript"]) +def test_exit_drains_private_callback_cancellation(kind): + script = """ +import atexit +import asyncio +import os +import sys +import threading +import time + +cancelling, finished = threading.Event(), threading.Event() +def check_callback_finished(): + if not finished.is_set(): + os._exit(5) +atexit.register(check_callback_finished) + +from bashkit import BashTool +async def slow(*args): + try: + await asyncio.sleep(30) + finally: + cancelling.set() + time.sleep(0.2) + finished.set() + +kind = sys.argv[1] +if kind == "builtin": + options = dict(custom_builtins={"slow": slow}) + command = "slow" +elif kind == "python": + options = dict(python=True, env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, + external_functions=["slow"], external_handler=slow) + command = "python -c 'slow()'" +else: + options = dict(typescript=True, typescript_external_functions=["slow"], + typescript_external_handler=slow) + command = "ts -c 'await slow()'" +tool = BashTool(timeout_seconds=0.1, **options) +assert tool.execute_sync(command).exit_code != 0 +assert cancelling.wait(2) +""" + preamble = "import sys; sys.path[:0] = %r\n" % [p for p in sys.path if p] + proc = subprocess.run([sys.executable, "-c", preamble + script, kind], capture_output=True, timeout=10) + assert proc.returncode == 0, proc.stderr.decode() diff --git a/crates/bashkit-python/tests/test_typescript_external_functions.py b/crates/bashkit-python/tests/test_typescript_external_functions.py new file mode 100644 index 000000000..5c3ff6b58 --- /dev/null +++ b/crates/bashkit-python/tests/test_typescript_external_functions.py @@ -0,0 +1,132 @@ +"""TypeScript host callbacks in the Python agent integrations.""" + +import asyncio + +import pytest + +from bashkit import BashTool + + +def test_typescript_external_handler(): + calls = [] + + async def handler(name, args): + calls.append((name, args)) + await asyncio.sleep(0) + return {"value": args[0] * args[1]["multiplier"]} + + tool = BashTool( + typescript=True, + typescript_external_functions=["lookup"], + typescript_external_handler=handler, + ) + result = tool.execute_sync("ts -c 'const r = await lookup(21, {multiplier: 2}); r.value'") + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + assert calls == [("lookup", [21, {"multiplier": 2}])] + + +@pytest.mark.parametrize( + "options, message", + [ + ({"typescript_external_functions": ["lookup"]}, "typescript_external_handler"), + ({"typescript_external_handler": lambda *args: None}, "typescript=True"), + ({"typescript": True, "typescript_external_handler": 42}, "callable"), + ({"typescript": True, "typescript_external_handler": lambda *args: 42}, "async"), + ], +) +def test_invalid_typescript_external_configuration(options, message): + with pytest.raises(ValueError, match=message): + BashTool(**options) + + +def test_both_languages_dispatch_independently_and_share_vfs(): + calls = [] + + async def python_handler(name, args, kwargs): + calls.append(("python", name, args, kwargs)) + return {"value": 21} + + async def typescript_handler(name, args): + calls.append(("typescript", name, args)) + return {"value": int(args[0]["value"]) * 2} + + tool = BashTool( + python=True, + typescript=True, + env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, + external_functions=["lookup"], + external_handler=python_handler, + typescript_external_functions=["lookup"], + typescript_external_handler=typescript_handler, + ) + result = tool.execute_sync( + 'python -c \'from pathlib import Path; n = Path("/tmp/value").write_text(str(lookup()["value"]))\'; ' + 'ts -c \'const r = await lookup({value: await readFile("/tmp/value")}); ' + 'await writeFile("/tmp/result", "" + r.value); r.value\'' + ) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42\n" + assert tool.read_file("/tmp/result") == "42" + assert calls == [("python", "lookup", [], {}), ("typescript", "lookup", [{"value": "21"}])] + for metadata in (tool.description(), tool.help(), tool.system_prompt()): + assert "Python host functions: lookup" in metadata + assert "TypeScript host functions: lookup" in metadata + + +def test_typescript_value_conversion_preserves_types(): + received = [] + + class Handler: + async def __call__(self, name, args): + received.append(args) + return {"items": args, "ok": True} + + tool = BashTool(typescript=True, typescript_external_functions=["lookup"], typescript_external_handler=Handler()) + result = tool.execute_sync( + 'ts -c \'const r = await lookup(null, undefined, true, false, 21, 0.5, "text", [1, 2], {key: "value"}); ' + "[r.items[4] * 2, r.ok]'" + ) + assert result.exit_code == 0, result.stderr + assert result.stdout == "42,true\n" + assert received == [[None, None, True, False, 21, 0.5, "text", [1, 2], {"key": "value"}]] + + +@pytest.mark.parametrize("value", [object(), float("nan"), float("inf"), 2**100, {1: "invalid-key"}]) +def test_typescript_rejects_non_json_handler_returns(value): + async def handler(name, args): + return value + + tool = BashTool(typescript=True, typescript_external_functions=["lookup"], typescript_external_handler=handler) + assert tool.execute_sync("ts -c 'await lookup()'").exit_code != 0 + + +def test_typescript_rejects_function_arguments_before_callback(): + calls = [] + + async def handler(name, args): + calls.append(args) + + tool = BashTool(typescript=True, typescript_external_functions=["lookup"], typescript_external_handler=handler) + result = tool.execute_sync("ts -c 'await lookup(() => 42)'") + assert result.exit_code != 0 + assert calls == [] + + +@pytest.mark.parametrize("direction", ["input", "output"]) +def test_typescript_conversion_depth_is_bounded(direction): + async def handler(name, args): + result = None + for _ in range(70): + result = [result] + return result + + tool = BashTool(typescript=True, typescript_external_functions=["lookup"], typescript_external_handler=handler) + script = ( + "await lookup()" + if direction == "output" + else ("let x = null; for (let i = 0; i < 70; i++) { x = [x]; } await lookup(x)") + ) + result = tool.execute_sync(f"ts -c '{script}'") + assert result.exit_code != 0 + assert "nesting depth" in result.stderr + (result.error or "") diff --git a/examples/runtime_external_functions.ipynb b/examples/runtime_external_functions.ipynb new file mode 100644 index 000000000..674971e2f --- /dev/null +++ b/examples/runtime_external_functions.ipynb @@ -0,0 +1,138 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "external-functions-0", + "metadata": {}, + "source": [ + "# Python and TypeScript external functions\n", + "\n", + "Host callbacks from real notebook cells: top-level await, blocking calls, and worker threads. No event-loop patching." + ] + }, + { + "cell_type": "code", + "id": "external-functions-1", + "metadata": {}, + "source": [ + "import asyncio\n", + "import contextvars\n", + "import threading\n", + "from bashkit import BashTool\n", + "\n", + "request_id = contextvars.ContextVar(\"request_id\", default=\"missing\")\n", + "caller_loop = asyncio.get_running_loop()\n", + "\n", + "async def handler(name, args, kwargs):\n", + " await asyncio.sleep(0)\n", + " return {\"value\": args[0] * kwargs[\"multiplier\"], \"request\": request_id.get(),\n", + " \"caller_loop\": asyncio.get_running_loop() is caller_loop}\n", + "\n", + "tool = BashTool(python=True, env={\"BASHKIT_ALLOW_INPROCESS_PYTHON\": \"1\"},\n", + " external_functions=[\"lookup\"], external_handler=handler)\n", + "script = \"python -c 'r = lookup(21, multiplier=2); print(r[\\\"value\\\"], r[\\\"request\\\"], r[\\\"caller_loop\\\"])'\"\n", + "request_id.set(\"async-cell\")\n", + "result = await tool.execute(script)\n", + "assert result.exit_code == 0, result.stderr\n", + "assert result.stdout == \"42 async-cell True\\n\"\n", + "print(result.stdout, end=\"\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "external-functions-2", + "metadata": {}, + "source": [ + "# A synchronous call inside the notebook loop uses an independent worker loop.\n", + "request_id.set(\"sync-cell\")\n", + "result = tool.execute_sync(script)\n", + "assert result.exit_code == 0, result.stderr\n", + "assert result.stdout == \"42 sync-cell False\\n\"\n", + "print(result.stdout, end=\"\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "external-functions-3", + "metadata": {}, + "source": [ + "# Reusing the same tool from another thread does not retain the prior caller loop.\n", + "def worker():\n", + " async def run():\n", + " request_id.set(\"worker-cell\")\n", + " return await tool.execute(script)\n", + " return asyncio.run(run())\n", + "\n", + "result = await asyncio.to_thread(worker)\n", + "assert result.exit_code == 0, result.stderr\n", + "assert result.stdout == \"42 worker-cell False\\n\"\n", + "tool.reset()\n", + "request_id.set(\"after-reset\")\n", + "result = await tool.execute(script)\n", + "assert result.stdout == \"42 after-reset True\\n\", result.stderr\n", + "print(\"NOTEBOOK_EXTERNAL_FUNCTIONS_OK\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "typescript-external-0", + "metadata": {}, + "source": [ + "# TypeScript functions receive (name, args); JSON objects carry named options.\n", + "async def ts_handler(name, args):\n", + " await asyncio.sleep(0)\n", + " return {\"value\": args[0] * args[1][\"multiplier\"], \"request\": request_id.get(),\n", + " \"caller_loop\": asyncio.get_running_loop() is caller_loop}\n", + "\n", + "ts_tool = BashTool(typescript=True, typescript_external_functions=[\"lookup\"],\n", + " typescript_external_handler=ts_handler)\n", + "# Return the final expression: upstream ZapCode loses console.log output after suspension.\n", + "ts_script = \"ts -c 'const r = await lookup(21, {multiplier: 2}); [r.value, r.request, r.caller_loop]'\"\n", + "request_id.set(\"ts-async-cell\")\n", + "result = await ts_tool.execute(ts_script)\n", + "assert result.exit_code == 0, result.stderr\n", + "assert result.stdout == \"42,ts-async-cell,true\\n\"\n", + "print(result.stdout, end=\"\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "typescript-external-1", + "metadata": {}, + "source": [ + "request_id.set(\"ts-sync-cell\")\n", + "result = ts_tool.execute_sync(ts_script)\n", + "assert result.exit_code == 0, result.stderr\n", + "assert result.stdout == \"42,ts-sync-cell,false\\n\"\n", + "ts_tool.reset()\n", + "request_id.set(\"ts-after-reset\")\n", + "result = await ts_tool.execute(ts_script)\n", + "assert result.stdout == \"42,ts-after-reset,true\\n\", result.stderr\n", + "print(\"NOTEBOOK_TYPESCRIPT_EXTERNAL_FUNCTIONS_OK\")" + ], + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/site/src/content/apidocs/python.md b/site/src/content/apidocs/python.md index 0aa4c0daf..211fafafd 100644 --- a/site/src/content/apidocs/python.md +++ b/site/src/content/apidocs/python.md @@ -119,8 +119,8 @@ Bash.execute_sync(commands: str, on_output: OutputHandler | None = None) -> Exec Execute bash commands synchronously (blocking). -Not supported when ``external_handler`` is configured — use -``execute()`` (async) instead. ``on_output`` must be synchronous. +Async external handlers use the instance's private worker loop. +Use ``await execute()`` for clients bound to the caller's running loop. ``on_output`` must be synchronous. Async ``custom_builtins`` callbacks run on a private loop here. When called from inside a running event loop (e.g. Jupyter / IPython), callbacks are dispatched to a background thread with their own loop so @@ -590,7 +590,7 @@ Adds LLM-facing contract metadata (``description``, ``system_prompt``, ### Constructor ```python -BashTool(username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False) -> None +BashTool(username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False, external_functions: list[str] | None = None, external_handler: ExternalHandler | None = None, typescript_external_functions: list[str] | None = None, typescript_external_handler: TypeScriptExternalHandler | None = None) -> None ``` Create a new BashTool. @@ -614,6 +614,16 @@ Create a new BashTool. - **`profile`** — Execution profile (Standard or Compatibility). - **`python`** — Register sandboxed Monty Python (default ``False``). - **`typescript`** — Register sandboxed ZapCode TypeScript (default ``False``). +- **`external_functions`** — Host function names callable from embedded Python. +- **`external_handler`** — Async ``(name, args, kwargs)`` Python dispatcher. +- **`typescript_external_functions`** — Host function names callable from TypeScript. +- **`typescript_external_handler`** — Async ``(name, args)`` TypeScript dispatcher. + +External handlers run on the caller's loop with ``await execute()`` and +on a dedicated private worker loop with ``execute_sync()``, including +inside notebooks. ContextVars are captured per execution. Handlers must +not re-enter this interpreter or its live filesystem handles. Reset +retains callbacks; snapshot factories require callbacks supplied again. Embedded runtimes share the shell's virtual filesystem and stay enabled after ``reset()``. Python additionally requires the host to provide @@ -954,7 +964,7 @@ Restore interpreter state from bytes produced by ``snapshot_keyed()``. ### `from_snapshot` ```python -BashTool.from_snapshot(data: bytes, username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False) -> BashTool +BashTool.from_snapshot(data: bytes, username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False, external_functions: list[str] | None = None, external_handler: ExternalHandler | None = None, typescript_external_functions: list[str] | None = None, typescript_external_handler: TypeScriptExternalHandler | None = None) -> BashTool ``` Create a new ``BashTool`` from snapshot bytes and optional constructor kwargs. @@ -962,7 +972,7 @@ Create a new ``BashTool`` from snapshot bytes and optional constructor kwargs. ### `from_snapshot_keyed` ```python -BashTool.from_snapshot_keyed(data: bytes, key: bytes, username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False) -> BashTool +BashTool.from_snapshot_keyed(data: bytes, key: bytes, username: str | None = None, hostname: str | None = None, cwd: str | None = None, env: Mapping[str, str] | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, max_memory: int | None = None, timeout_seconds: float | None = None, files: dict[str, str] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, custom_builtins: Mapping[str, BuiltinCallback] | None = None, network: NetworkConfig | None = None, profile: ExecutionProfile = ExecutionProfile.Standard, python: bool = False, typescript: bool = False, external_functions: list[str] | None = None, external_handler: ExternalHandler | None = None, typescript_external_functions: list[str] | None = None, typescript_external_handler: TypeScriptExternalHandler | None = None) -> BashTool ``` Create a new ``BashTool`` from HMAC-protected snapshot bytes. @@ -1923,7 +1933,7 @@ bashkit.langchain.PrivateAttr(*args, **kwargs) ### `create_bash_tool` ```python -bashkit.langchain.create_bash_tool(username: str | None = None, hostname: str | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, timeout_seconds: float | None = None, python: bool = False, typescript: bool = False, env: dict[str, str] | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, max_output_length: int = 100000) -> BashkitTool +bashkit.langchain.create_bash_tool(username: str | None = None, hostname: str | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None, timeout_seconds: float | None = None, python: bool = False, typescript: bool = False, env: dict[str, str] | None = None, files: dict[str, str | Callable[[], str]] | None = None, mounts: list[dict[str, Any]] | None = None, allowed_mount_paths: list[str] | None = None, readonly_filesystem: bool = False, max_output_length: int = 100000, external_functions: list[str] | None = None, external_handler: ExternalHandler | None = None, typescript_external_functions: list[str] | None = None, typescript_external_handler: TypeScriptExternalHandler | None = None) -> BashkitTool ``` Create a LangChain-compatible Bashkit tool. @@ -1940,6 +1950,10 @@ Create a LangChain-compatible Bashkit tool. - **`allowed_mount_paths`** — Host path prefixes allowed for real filesystem mounts. Required when mounting sensitive host locations such as paths under a user home directory. - **`readonly_filesystem`** — Deny all filesystem mutations after configured files and mounts are applied. - **`max_output_length`** — Maximum number of characters returned to the LangChain agent from one bash tool call before truncation. +- **`external_functions`** — Host function names callable from embedded Python. +- **`external_handler`** — Async dispatcher receiving ``(name, args, kwargs)``. With ``ainvoke`` it runs on the caller's loop; ``invoke`` uses a private worker loop. Requires ``python=True`` and the host env grant. +- **`typescript_external_functions`** — Host function names callable from TypeScript. +- **`typescript_external_handler`** — Async ``(name, args)`` dispatcher with JSON values and the same loop ownership rules. Requires ``typescript=True``. **Returns:** BashkitTool instance for use with LangChain agents From fdf096df6d373421046549b4900d559ef857197e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 16 Oct 2026 17:48:54 +0000 Subject: [PATCH 2/3] chore(knowledge): document runtime callback lifecycle and validation --- knowledge/log.md | 4 ++ knowledge/runtimes/emscripten-wheels.md | 2 +- knowledge/runtimes/python-package.md | 76 ++++++++++++++++++++++++- knowledge/security/threat-model.md | 23 ++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/knowledge/log.md b/knowledge/log.md index 61c26015c..1102284ab 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,9 @@ # Bashkit Knowledge Update Log +## 2026-10-16 + +* **Python / agent integrations**: Added Python and TypeScript host function callbacks to `BashTool`, LangChain and deepagents. Reuse per-execution callback sessions for caller-loop affinity, ContextVars and cancellation; sync execution uses the dedicated private worker loop. Reentry is callback-context-specific so independent requests queue safely. Private-loop timeout cancellation now targets its own work item; native result deliveries drain before interpreter finalization to prevent a shutdown race. Added FastAPI, real Jupyter, alternate-loop and cross-thread regression coverage plus macOS/Windows callback CI. See [Python Package](runtimes/python-package.md). + ## 2026-10-15 * **npm publication**: The 0.19.0 browser upload returned a staged-version E409, but public registry read-back showed the version and `latest` tag. Both npm lanes now publish one retained archive and require its SHA-512 integrity plus the intended public dist-tag. Only the specific staged conflict enters bounded verification; authentication errors and private or mismatched artifacts still fail. See [Release Process](operations/release-process.md). diff --git a/knowledge/runtimes/emscripten-wheels.md b/knowledge/runtimes/emscripten-wheels.md index 742d0bd81..0a8eb9fbd 100644 --- a/knowledge/runtimes/emscripten-wheels.md +++ b/knowledge/runtimes/emscripten-wheels.md @@ -50,7 +50,7 @@ no caller-loop). Absent on wasm: async `execute()` / `execute_or_throw()` (methods absent), `FileSystem.real()` / capsule `to/from_capsule` (methods absent). Gated-off *configuration* kwargs, `network=`, `sqlite=True`, `mounts=`, -`external_handler=`, **fail loudly** with `RuntimeError` at construction +`external_handler=` / `typescript_external_handler=`, **fail loudly** with `RuntimeError` at construction rather than silently no-op, so callers learn immediately the WASM build can't do it. diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index 9aa44e4a4..f996760ff 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -120,6 +120,80 @@ defaults, independent runtime selection, metadata, sync/async execution, shared VFS, reset, keyed/unkeyed snapshot factories, framework forwarding, invalid programs, and rejection of script-only Python opt-in. +### Embedded runtime host functions + +`BashTool` exposes Python `external_functions` / `external_handler`, matching +`Bash`; handlers are async `(name, args, kwargs)` callables. TypeScript has +separate `typescript_external_functions` / `typescript_external_handler` +kwargs on `BashTool`, with an async `(name, args)` callable. Both are forwarded +by LangChain and deepagents. Language selection and the explicit `BashTool` +Python host-env grant remain mandatory; handlers grant no other runtime access. +Names appear in tool metadata; hosts supply their function signatures. + +Decision: use the existing `PyCallbackEngine` / per-execution +`PyCallbackSession` via `RuntimeCallContext` request extensions. Do not cache +caller event loops on handlers or derive them on a Tokio worker. Each async +execution schedules handlers on its caller's loop and captures that request's +ContextVars. Sync execution uses the instance's dedicated private-loop worker; +this includes FastAPI sync endpoints, ordinary threads and synchronous notebook +cells. A client owned by an outer loop requires async execution instead. +`Bash.execute_sync()` now supports external handlers through this same engine. +Deepagents provides native coroutine implementations for backend `aexecute` +and middleware `ainvoke` rather than offloading sync execution to a thread. + +Same-instance calls serialize on the existing interpreter mutex. Reentry +protection uses a per-instance ContextVar set inside the handler task, not an +instance-wide activity counter: recursive interpreter/live-FS access fails +immediately, while unrelated requests can queue during callback I/O. Other +interpreter instances are usable from a handler. Private-loop work items carry +their own cancellation state/task handle so timeout/revocation cancels that +item even if it has not started; cancelling an old item cannot cancel the next +request on the shared loop. Worker teardown retains the interpreter-exit guard. + +Native result delivery has an additional exit boundary in `src/async_bridge.rs`. +Decision: retain upstream `pyo3-async-runtimes` cancellation, panic and task-local +behavior through its generic runtime interface, while enrolling blocking result +deliveries under a shared shutdown lock. A weak registry first closes private +loop creation, cancels outstanding private callbacks and joins their workers +while Python is still attachable. This also drains timeout-cancellation cleanup; +setting only an exit flag leaves a worker already inside Python racing finalization. +The atexit hook then closes result-delivery enrollment and +releases the GIL until enrolled deliveries finish releasing Python references. +Without that drain, `call_soon_threadsafe` can wake Python, which exits while +the native worker still owns attached references; CPython can destroy its thread +state before tuple cleanup, causing a native crash. Future deliveries skip Python +after the boundary closes. The deterministic subprocess test delays a native +delivery after publishing the result and checks Bash, BashTool and ScriptedTool; +parallel subprocess churn also exercises immediate exit after runtime callbacks. +Another deterministic test delays cancellation cleanup in private callbacks for +custom builtins, embedded Python and TypeScript, then immediately exits. + +A per-instance count of active handlers on each Python thread protects sync +entry points too: another task on the handler's own loop cannot synchronously +wait for the interpreter lock without deadlocking that loop. Those sync calls +fail fast with an async/to-thread hint; async calls and sync callers on other +threads still queue. The subprocess regression bounds a native deadlock and +checks retained live-FS handles as well as execution and snapshot APIs. Guards +skip Python lookups entirely when no external handlers are configured. + +Callbacks are host configuration: preserved on reset/in-place restore, supplied +again to keyed/unkeyed snapshot factories, never serialized. Both native +external-handler kwargs fail loudly on Pyodide. TypeScript conversions accept +JSON-shaped values, map undefined to None, cap nesting at 64, and reject function +inputs, non-finite numbers, unsupported outputs and integers outside i64. +Use a final expression or VFS output after TypeScript host calls; upstream +ZapCode does not retain post-suspension console.log output. + +Regression evidence: `tests/test_external_functions.py` shares execution-model +coverage across Python/TypeScript (sync/async, multiple threads, successive loops, +Selector/uvloop, callback cancellation, timeout/reuse, reentry, mixed builtins, +FastAPI and framework invocation). `tests/test_typescript_external_functions.py` +covers direct TypeScript dispatch/conversion and both languages sharing the VFS. +`examples/runtime_external_functions.ipynb` runs in a real Jupyter kernel in CI. +Python CI uses a thread-based per-test watchdog so a native deadlock cannot +leave a runner hanging indefinitely. It runs the callback suite on macOS/Windows in addition to the Linux +3.9–3.14 matrix; the Windows default tests Proactor and explicit Selector loops. + ### Runtime host mutations `set_env()` and runtime `mount()` are recorded and replayed on every rebuild, @@ -270,7 +344,7 @@ invocation regardless of mechanism. ## Optional Dependencies `bashkit[langchain]`, `bashkit[deepagents]`, `bashkit[pydantic-ai]`, -`bashkit[dev]` (pytest, pytest-asyncio). +`bashkit[dev]` (pytest, pytest-asyncio, pytest-timeout). ## Deep Agents backend diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index c3e72e28b..33465973d 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -1969,6 +1969,29 @@ re-entering Python no longer race the caller's GIL hold. **TM-PY-027** (mitigated): see table. Coverage: `tests/_security_advanced.py::JsonConversionBoundariesTests`. +Python-package external handlers now share the lease-bound callback sessions +used by custom builtins. Pending private-loop tasks have per-work-item +cancellation: a timeout cancels that callback instead of leaving host I/O running +until tool teardown, without cancelling the next request on the shared loop. +Reentry detection follows the handler's ContextVar (including child tasks), so +concurrent unrelated requests can queue without gaining recursive live-VFS +access. Evidence: `test_external_functions.py` timeout/reuse, queued requests, +caller-loop/context, cancellation and retained-FS-handle regressions, shared +across embedded Python and TypeScript. TypeScript value conversion is bounded +at 64 levels and rejects functions and non-JSON host values before execution +continues (`test_typescript_external_functions.py`). + +Native async result deliveries enroll before entering Python. The atexit hook +first closes private-loop creation, cancels callbacks and joins private workers +while Python is still attachable, including callbacks unwinding after timeout. +It then closes enrollment and drains already-enrolled deliveries with the GIL released; +new deliveries skip Python. This prevents a worker's post-publication reference +cleanup racing CPython finalization, which can otherwise segfault after a fully +awaited execution. `test_teardown_determinism.py::test_exit_waits_for_native_result_delivery_to_return` +forces this window for Bash, BashTool and ScriptedTool (TM-PY-030). +`test_exit_drains_private_callback_cancellation` forces cancellation cleanup to +overlap process exit for custom builtins and both embedded runtimes. + **TM-PY-030** (mitigated): three crash/deadlock variants in the async-callback private-loop machinery (`crates/bashkit-python/src/lib.rs`), resolved by a deterministic teardown protocol and root-cause channel fix. (1) `PyPrivateAsyncLoop::run_awaitable` From 7dafe0c9e087d66a196914ad8f2023d4b45dd3de Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 16 Oct 2026 18:03:11 +0000 Subject: [PATCH 3/3] test(python): preserve wheel imports in callback subprocesses --- crates/bashkit-python/tests/test_external_functions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/bashkit-python/tests/test_external_functions.py b/crates/bashkit-python/tests/test_external_functions.py index 091a31bec..8926f34db 100644 --- a/crates/bashkit-python/tests/test_external_functions.py +++ b/crates/bashkit-python/tests/test_external_functions.py @@ -217,7 +217,12 @@ async def handler(*args): assert (await queued).stdout == "queued\\n" asyncio.run(main()) """) - result = subprocess.run([sys.executable, "-c", code, runtime.name], capture_output=True, text=True, timeout=8) + # Wheel CI runs from the Python package directory. Match the parent's + # import paths so the source checkout cannot shadow the installed wheel. + preamble = "import sys; sys.path[:0] = %r\n" % [p for p in sys.path if p] + result = subprocess.run( + [sys.executable, "-c", preamble + code, runtime.name], capture_output=True, text=True, timeout=8 + ) assert result.returncode == 0, result.stdout + result.stderr