This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 96da58058ef Add Islo backend for sandbox tools (#71672)
96da58058ef is described below
commit 96da58058efd9648401722458d90e8b8a8aa4a95
Author: Yossi Eliaz <[email protected]>
AuthorDate: Wed Oct 14 10:23:08 2026 +0300
Add Islo backend for sandbox tools (#71672)
---
docs/spelling_wordlist.txt | 2 +
providers/common/ai/docs/index.rst | 1 +
providers/common/ai/docs/installation.rst | 7 +-
providers/common/ai/docs/sandbox/backends.rst | 119 +-
providers/common/ai/docs/sandbox/index.rst | 37 +-
providers/common/ai/provider.yaml | 3 +
providers/common/ai/pyproject.toml | 4 +
.../providers/common/ai/get_provider_info.py | 1 +
.../providers/common/ai/sandbox/__init__.py | 6 +
.../airflow/providers/common/ai/sandbox/islo.py | 757 ++++++++++++
.../common/ai/tests/system/common/ai/README.md | 12 +
.../common/ai/example_sandbox_toolset_islo.py | 134 ++
.../ai/tests/unit/common/ai/sandbox/test_islo.py | 1276 ++++++++++++++++++++
uv.lock | 22 +-
14 files changed, 2348 insertions(+), 33 deletions(-)
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index ca29e970906..00f4f3081b5 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -216,6 +216,7 @@ bugfix
bugfixes
buildType
burstable
+busybox
bytestring
cacert
Cadwyn
@@ -910,6 +911,7 @@ irreproducible
IRSA
isfile
ish
+islo
isn
isort
istio
diff --git a/providers/common/ai/docs/index.rst
b/providers/common/ai/docs/index.rst
index 33f18f69880..3962b0bd468 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -232,6 +232,7 @@ Extra Dependencies
``code-mode`` ``pydantic-ai-harness[codemode]>=0.24.0``
``shields`` ``pydantic-ai-shields>=0.3.4``
``skills`` ``apache-airflow-providers-git>=0.4.0``,
``pydantic-ai-skills>=1.2.0``
+``islo`` ``islo>=0.3.19``
``avro`` ``fastavro>=1.10.0; python_version < "3.14"``,
``fastavro>=1.12.1; python_version >= "3.14"``
``parquet`` ``pyarrow>=18.0.0; python_version < '3.14'``,
``pyarrow>=22.0.0; python_version >= '3.14'``
``sql`` ``apache-airflow-providers-common-sql>=2.2.0``,
``sqlglot>=30.0.0``
diff --git a/providers/common/ai/docs/installation.rst
b/providers/common/ai/docs/installation.rst
index a5451a09e16..d7ab8dd7417 100644
--- a/providers/common/ai/docs/installation.rst
+++ b/providers/common/ai/docs/installation.rst
@@ -48,9 +48,10 @@ The provider's extras split into a few groups:
than these, each under its own extra name, so check the
`pydantic-ai install docs <https://ai.pydantic.dev/install/#slim-install>`__
for the full list.
* **Agent tooling** (``mcp``, ``skills``, ``code-mode``, ``shields``,
``modal``,
- ``opensandbox``, ``boat``): MCP servers, Agent Skills, code-mode tool
execution, shield
- capabilities (input/output guards, tool guards, cost tracking), and the
hosted Modal and
- Boat and self-hosted OpenSandbox backends for :doc:`sandboxed execution
<sandbox/index>`.
+ ``opensandbox``, ``boat``, ``islo``): MCP servers, Agent Skills, code-mode
tool
+ execution, shield capabilities (input/output guards, tool guards, cost
tracking), and
+ the hosted Modal, Boat and Islo backends and the self-hosted OpenSandbox
backend for
+ :doc:`sandboxed execution <sandbox/index>`.
* **Document loading** (``pdf``, ``docx``, ``avro``, ``parquet``): file
formats for
document pipelines.
* **Retrieval / SQL** (``sql``, ``common.sql``, ``langchain``,
``llamaindex``): RAG and
diff --git a/providers/common/ai/docs/sandbox/backends.rst
b/providers/common/ai/docs/sandbox/backends.rst
index 63928eef8f4..2ecc67a142e 100644
--- a/providers/common/ai/docs/sandbox/backends.rst
+++ b/providers/common/ai/docs/sandbox/backends.rst
@@ -31,12 +31,12 @@ Modal (hosted)
:class:`~airflow.providers.common.ai.sandbox.modal.ModalSandboxBackend` runs
each
sandbox in Modal, provisioned over the API. Of the backends that ship with the
provider, **this is the managed one to use in production**, and with
-:ref:`OpenSandbox <sandbox-backend-opensandbox>` one of the two that run on
-Kubernetes: nothing has to be installed on the worker, model-written code never
-executes on the worker host, and Modal reclaims a sandbox at its own lifetime
-whether or not the worker survives. It needs the ``modal`` extra and Modal
-credentials, from a ``modal`` connection or the worker environment, as under
-:ref:`Quick start <sandbox-quick-start>`.
+:ref:`OpenSandbox <sandbox-backend-opensandbox>`, :ref:`Boat
<sandbox-backend-boat>` and
+:ref:`Islo <sandbox-backend-islo>` one of the four that run on Kubernetes:
nothing has to
+be installed on the worker, model-written code never executes on the worker
host, and Modal reclaims
+a sandbox at its own lifetime whether or not the worker survives. It needs the
+``modal`` extra and Modal credentials, from a ``modal`` connection or the
worker
+environment, as under :ref:`Quick start <sandbox-quick-start>`.
Constructor parameters:
@@ -185,6 +185,92 @@ them.
that is a symlink replaces the link with a regular file and leaves the original
target untouched, where a shell redirect would follow the link.
+.. _sandbox-backend-islo:
+
+Islo (hosted microVM)
+---------------------
+
+:class:`~airflow.providers.common.ai.sandbox.islo.IsloSandboxBackend` runs each
+sandbox in an `islo.dev <https://islo.dev>`__ microVM, provisioned over the
API.
+Like Modal and unlike ``sbx``, the worker only speaks HTTP: it needs no local
+daemon and no host virtualization, so it runs from a containerized worker and
on
+Kubernetes.
+
+Install the SDK extra:
+
+.. code-block:: bash
+
+ pip install "apache-airflow-providers-common-ai[islo]"
+
+.. code-block:: python
+
+ from airflow.providers.common.ai.sandbox import IsloSandboxBackend
+ from airflow.providers.common.ai.toolsets import SandboxToolset
+
+ SandboxToolset(IsloSandboxBackend())
+
+Credentials are ambient. On first use the SDK reads ``ISLO_API_KEY``, and
optionally
+``ISLO_BASE_URL`` and ``ISLO_COMPUTE_URL``, from the worker environment.
Modal's
+``modal`` connection comes from the Modal provider; an ``islo`` connection
type waits
+for an Islo provider.
+
+Constructor parameters:
+
+- ``image``, ``vcpus``, ``memory_mb``: image and sizing. ``None`` (default)
uses
+ the server default for each. The server default image is Debian based, and
any
+ Debian or Ubuntu based image, including ``python:*-slim``, has the GNU
``find``,
+ ``stat`` and ``tail`` the file operations need; Alpine and other busybox
images
+ do not.
+- ``pause_after_idle``: Seconds without a command or file operation after which
+ the server pauses the microVM and stops charging for its compute. Default
+ ``600``; ``None`` disables it.
+- ``auto_resume``: ``"on_activity"`` (default) resumes a paused sandbox on the
+ next tool call, so a long think between calls costs a resume rather than the
+ run; ``"never"`` leaves it paused, and the backend then treats a paused
sandbox
+ as unusable.
+- ``delete_after``: Seconds after **creation** at which the server deletes the
+ sandbox, whether or not it is in use. Default ``86400``; ``None`` disables
it.
+ This is the backstop for a worker killed mid-run; it is not renewed while the
+ sandbox is busy, so keep it longer than the longest run you expect.
+
+``SandboxSpec.env`` becomes the process environment of every command, and
+``block_network`` maps to the API's ``internet_enabled``. Four things Islo
cannot
+do are refused at ``create`` rather than silently dropped: ``allow_egress_to``
and
+``allow_egress_to_cidrs`` (the API turns outbound access on or off, with no
+per-host or per-address rule), a ``PATH`` entry in ``env`` (the runner sets
+``PATH`` for every command itself), and ``owner`` (this backend keeps no
+per-sandbox metadata, so it cannot be attached to from another task).
+
+File reads, writes and exports move file contents through Islo's native
+streaming APIs. Everything else runs through a shell wrapper in the sandbox, so
+the image needs ``sh``, ``mkdir`` and ``rm`` for the wrapper, ``tail`` to bound
+command output, ``dirname`` to create a written file's parent directory,
+``stat`` to size an over-budget read and to check a file before it is exported,
+``realpath`` to resolve a relative export path (the download API takes absolute
+paths only), and GNU ``find`` for directory listings. The compute API caps
each output stream
+at 1 MiB and keeps the tail. The backend captures each stream to a scratch
file in
+the sandbox and returns only its last ``max_output_bytes`` -- where a traceback
+and the exit status live -- so the worker sees a window sized to the caller's
+budget, a budget above the server cap is clamped to it, and total output is
+bounded by the sandbox's own ephemeral disk rather than by worker memory.
+
+The backend enforces the command deadline itself, because the API's
+``timeout_secs`` is accepted but not enforced, and times it from the API's
answer
+to the start request, so a slow start does not use it up. Polling rides out
+transient API errors until the deadline. If the command is still running when
+the deadline passes, the backend deletes the microVM and reports the command as
+timed out with ``sandbox_terminated`` set, so the toolset provisions a fresh
+sandbox for the next call. A deletion the API refused is logged rather than
+failing the task, and ``delete_after`` reclaims the microVM later; with
+``delete_after=None`` nothing does, and the warning says so. If the API was
still
+failing at the deadline, nothing is known about the command, so the task fails
+instead and Airflow's retry takes over.
+
+A missing file, a directory passed as a file, a relative path, and a write
onto a
+directory or under a pseudo-filesystem such as ``/proc`` or ``/sys`` come back
to
+the model as a recoverable error it can correct, once the backend has confirmed
+the sandbox itself is still usable.
+
.. _sandbox-backend-opensandbox:
OpenSandbox (self-hosted remote)
@@ -441,17 +527,18 @@ behaves identically everywhere:
- **CPU.** ``sbx`` gives a sandbox every host CPU; Modal defaults to a request
of
0.125 of one, so set ``cpu``; OpenSandbox takes ``cpu`` as a limit the
server enforces;
- Boat sizes by ``machine_type``.
+ Boat sizes by ``machine_type``; Islo uses the server default unless you set
``vcpus``.
- **Egress allowlists.** ``sbx`` enforces ``allow_egress_to`` at the host
policy
layer; Modal matches TLS handshake names, which is weaker and has to be opted
into; OpenSandbox enforces it in an egress sidecar, and the backend reads the
enforced policy and the sidecar's mode back rather than trusting the create
request, refusing a sidecar that filters only name resolution (``dns``)
rather
than connections (``dns+nft``). ``allow_egress_to_cidrs`` is enforced at the
- address layer on Modal and refused on ``sbx`` and OpenSandbox. Boat has no
form of
- egress control at all and refuses every network restriction rather than
- provisioning something weaker.
-- **Command timeouts.** A timeout destroys an ``sbx`` sandbox and its files;
+ address layer on Modal and refused on ``sbx``, OpenSandbox and Islo. Islo
has no
+ per-host form and refuses the spec; Boat has no form of egress control at
all and
+ refuses every network restriction. Both refuse rather than provision
something
+ weaker.
+- **Command timeouts.** A timeout destroys an ``sbx`` or Islo sandbox and its
files;
Modal, Boat and a server-enforced OpenSandbox timeout preserve the sandbox
and
files. OpenSandbox destroys it only if the command event stream itself stalls
past the client-side grace period, and Boat only if its own deadline, set
past
@@ -461,9 +548,9 @@ behaves identically everywhere:
with a regular file on Modal and OpenSandbox.
- **Attaching.** A Modal sandbox can be provisioned by one task and used by an
agent in another (:ref:`sandbox-attach`). An ``sbx`` microVM lives on the
worker
- that created it and cannot be reached from another task, and OpenSandbox and
Boat
- have no per-sandbox metadata the ownership rules could be kept in, so all
three refuse
- ``SandboxSpec.owner`` and the toolset refuses ``attach_to`` for them.
+ that created it and cannot be reached from another task, and OpenSandbox,
Boat and
+ Islo have no per-sandbox metadata the ownership rules could be kept in, so
all four
+ refuse ``SandboxSpec.owner`` and the toolset refuses ``attach_to`` for them.
.. _sandbox-byo:
@@ -481,8 +568,8 @@ commands. The default ``export_file``, behind
``SandboxToolset(exports=...)``,
copies a file in 4 MiB slices, one command each, and needs ``stat``, ``tail``,
``head`` and ``base64`` in the guest. It relies on ``run_command`` returning
each
slice's output intact, or setting ``stdout_truncated`` when it could not.
Override
-it when the vendor can stream a download, as the ``sbx`` and OpenSandbox
backends do. Override the others
-only when the vendor has a native file API:
+it when the vendor can stream a download, as the ``sbx``, OpenSandbox and Islo
+backends do. Override the others only when the vendor has a native file API:
.. code-block:: python
diff --git a/providers/common/ai/docs/sandbox/index.rst
b/providers/common/ai/docs/sandbox/index.rst
index 7ec165073c7..9e58c25f0c3 100644
--- a/providers/common/ai/docs/sandbox/index.rst
+++ b/providers/common/ai/docs/sandbox/index.rst
@@ -73,7 +73,7 @@ stays where the task runs:
end
sbx["sbx microVM<br/>local development only"]
end
- remote["Modal, OpenSandbox or Boat sandbox<br/>run_command,
read_file,<br/>write_file, list_directory"]
+ remote["Modal, OpenSandbox, Boat or Islo sandbox<br/>run_command,
read_file,<br/>write_file, list_directory"]
model <--> loop
db <--> other
loop <--> other
@@ -89,11 +89,13 @@ agent loop, the model calls and every other toolset keep
the task's authority, s
sandbox contains model-written code, not the agent (:ref:`sandbox-security`).
Under
``KubernetesExecutor`` the outer box is the task's pod: the pod protects the
cluster
from the task, and the sandbox protects the task from its model. An ``sbx``
microVM
-cannot run inside an unprivileged pod, so that combination needs Modal,
OpenSandbox or Boat.
+cannot run inside an unprivileged pod, so that combination needs Modal,
OpenSandbox, Boat
+or Islo.
The credential that provisions the sandbox depends on the backend: a ``modal``
connection (``modal_conn_id``; see the :ref:`Modal connection page
<howto/connection:modal>`)
for Modal; ``boat_conn_id``, or ``BOAT_API_KEY`` and optional
``BOAT_BASE_URL``, for Boat;
+``ISLO_API_KEY`` (and optional ``ISLO_BASE_URL`` and ``ISLO_COMPUTE_URL``) for
Islo;
``opensandbox_conn_id``, or ``OPEN_SANDBOX_DOMAIN`` and
``OPEN_SANDBOX_API_KEY``,
for OpenSandbox; and the host's ``sbx login`` for ``sbx``.
@@ -118,9 +120,9 @@ The four tools:
- Lists a directory. Directories are shown with a trailing ``/``.
A :class:`~airflow.providers.common.ai.sandbox.SandboxBackend` provisions the
sandbox
-on the model's first tool call and tears it down when the agent run ends. Four
-backends ship: `Modal <https://modal.com/docs/guide/sandbox>`__ and
-`Boat <https://docs.boat.dev/quickstart>`__ (hosted) and
+on the model's first tool call and tears it down when the agent run ends. Five
+backends ship: `Modal <https://modal.com/docs/guide/sandbox>`__,
+`Boat <https://docs.boat.dev/quickstart>`__ and `Islo <https://islo.dev>`__
(hosted) and
`OpenSandbox <https://open-sandbox.ai/>`__ (self-hosted) for production and
Kubernetes, and `Docker Sandboxes <https://docs.docker.com/ai/sandboxes/>`__
(``sbx``,
a microVM on the worker host) for local development. The tool names are the
ones
@@ -354,9 +356,15 @@ leaves its contents alone: the same four tools run in the
sandbox.
- Boat's infrastructure, off the worker.
- Stopped by Boat at ``ttl_seconds``. Sandboxes are created without
snapshots
or monitoring, so stopping one erases its disk and Boat keeps no
command history.
+ * - ``IsloSandboxBackend``
+ - A microVM that Islo provisions per sandbox.
+ - Islo's infrastructure, off the worker.
+ - Deleted by Islo at ``delete_after``, counted from creation. ``None``
+ disables that backstop.
When a run ends normally, the task calls the backend's ``destroy``. ``sbx``
runs its
-removal command and waits up to two minutes for it; Modal, OpenSandbox and
Boat each send a
+removal command and waits up to two minutes for it; Modal, OpenSandbox, Boat
and Islo each
+send a
termination request and return without waiting for the sandbox to stop. Any of
them
can return with the sandbox still present, and none of those cases fails the
task. A
SIGKILL, an out-of-memory kill or a lost node skips that teardown entirely,
and then
@@ -405,7 +413,7 @@ whatever it wants inside the pod that also holds your
credentials.
- Only what ``SandboxSpec.env`` names, which is nothing by default
Running an agent in a pod *and* giving it a sandbox is the setup a Kubernetes
-deployment usually wants, with Modal, OpenSandbox or Boat as the backend.
+deployment usually wants, with Modal, OpenSandbox, Boat or Islo as the backend.
When to choose it
-----------------
@@ -423,7 +431,7 @@ task, a script the model writes, runs, and fixes from its
own traceback.
:ref:`sandbox-security`.
- **Nothing survives the run** in a sandbox the toolset provisions itself,
including across task retries. On Modal, a sandbox a task provisions and the
agent
- attaches to does; ``sbx``, OpenSandbox and Boat cannot be attached to.
+ attaches to does; ``sbx``, OpenSandbox, Boat and Islo cannot be attached to.
:ref:`Lifecycle <sandbox-lifecycle>`,
:ref:`A sandbox another task owns <sandbox-attach>`.
- **A file the agent built leaves through** ``exports`` **or a task**, never
through
@@ -446,10 +454,12 @@ task, a script the model writes, runs, and fixes from its
own traceback.
its address allowlist is enforced properly but cannot serve a package
registry whose
addresses rotate. Boat cannot restrict egress at all, so it refuses every
network
restriction, including the default ``block_network=True``: pass
- ``SandboxSpec(block_network=False)`` to accept open egress there.
+ ``SandboxSpec(block_network=False)`` to accept open egress there. Islo turns
outbound
+ access on or off and refuses hostname and CIDR allowlists.
:ref:`Configuring a sandbox <sandbox-configuring>`,
:ref:`Modal <sandbox-backend-modal>`,
- :ref:`Boat <sandbox-backend-boat>`.
+ :ref:`Boat <sandbox-backend-boat>`,
+ :ref:`Islo <sandbox-backend-islo>`.
- **On Modal, commands run as root and** ``workdir`` **is not a jail.**
:ref:`Modal <sandbox-backend-modal>`.
- **On Boat, commands can become root through** ``sudo``, **the sandbox has a
public
@@ -458,7 +468,7 @@ task, a script the model writes, runs, and fixes from its
own traceback.
- **The** ``sbx`` **backend is for local development.** It needs the ``sbx``
binary,
a Docker login and, on Linux, KVM or nested virtualization, so it does not
run on
unprivileged Kubernetes, and a worker killed outright leaves its microVM
behind.
- Production and Kubernetes use Modal, OpenSandbox or Boat instead.
+ Production and Kubernetes use Modal, OpenSandbox, Boat or Islo instead.
:ref:`sbx <sandbox-backend-sbx>`.
- **A failed teardown is logged, not raised**, so a teardown blip cannot fail a
finished run; reclaiming the sandbox is then the backend's lifetime or an
@@ -469,13 +479,14 @@ has the two agents in the quick start, a ``@task``
producing a file through a
sandbox, an agent attaching to a task-owned sandbox, and an agent exporting a
file,
shown on this page and in :doc:`configuration`. The system tests
``example_sandbox_toolset_sbx.py``, ``example_sandbox_toolset_modal.py``,
-``example_sandbox_toolset_opensandbox.py`` and
``example_sandbox_toolset_boat.py``
+``example_sandbox_toolset_opensandbox.py``,
``example_sandbox_toolset_boat.py`` and
+``example_sandbox_toolset_islo.py``
run against a real backend and are
reachable from the System Tests entry in the sidebar.
**Credentials and where it runs.** Only what ``SandboxSpec.env`` names enters
the
sandbox; the provisioning credential for each backend is listed under
-:ref:`sandbox-placement`. Work runs in Modal's or Boat's infrastructure, on the
+:ref:`sandbox-placement`. Work runs in Modal's, Boat's or Islo's
infrastructure, on the
OpenSandbox server's runtime (off the worker unless you run the server there),
or in a per-run
microVM on the worker host with ``sbx``. Its tool calls act as barriers, as
they do
for the other routes that build their own tools; see
:ref:`toolset-call-barriers`.
diff --git a/providers/common/ai/provider.yaml
b/providers/common/ai/provider.yaml
index cfb9e9c5643..4aa3d8112d6 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -74,6 +74,9 @@ integrations:
- integration-name: Modal Sandboxes
external-doc-url: https://modal.com/docs/guide/sandbox
tags: [service]
+ - integration-name: Islo
+ external-doc-url: https://docs.islo.dev/
+ tags: [service]
- integration-name: OpenSandbox
external-doc-url: https://open-sandbox.ai/
tags: [software]
diff --git a/providers/common/ai/pyproject.toml
b/providers/common/ai/pyproject.toml
index 4f7ab1faa68..7078737fbf7 100644
--- a/providers/common/ai/pyproject.toml
+++ b/providers/common/ai/pyproject.toml
@@ -125,6 +125,9 @@ dependencies = [
"apache-airflow-providers-git>=0.4.0",
"pydantic-ai-skills>=1.2.0",
]
+# Islo-hosted microVM sandboxes for SandboxToolset: like modal, needs no
host-level
+# installation, only ISLO_API_KEY in the worker environment.
+"islo" = ["islo>=0.3.19"]
"avro" = [
'fastavro>=1.10.0; python_version < "3.14"',
'fastavro>=1.12.1; python_version >= "3.14"',
@@ -182,6 +185,7 @@ dev = [
"openai>=2.47.0",
"anthropic>=1.0.0",
"boat-sdk>=1.9.3",
+ "islo>=0.3.19",
"opensandbox>=1.1.0",
]
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
index 0e518bd7e1d..100f4f067ef 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
@@ -76,6 +76,7 @@ def get_provider_info():
"external-doc-url": "https://modal.com/docs/guide/sandbox",
"tags": ["service"],
},
+ {"integration-name": "Islo", "external-doc-url":
"https://docs.islo.dev/", "tags": ["service"]},
{
"integration-name": "OpenSandbox",
"external-doc-url": "https://open-sandbox.ai/",
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/sandbox/__init__.py
b/providers/common/ai/src/airflow/providers/common/ai/sandbox/__init__.py
index 3f588c62681..3e770ed26b7 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/sandbox/__init__.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/sandbox/__init__.py
@@ -29,12 +29,18 @@ from airflow.providers.common.ai.sandbox.base import (
dag_run_owner,
)
from airflow.providers.common.ai.sandbox.boat import BoatSandboxBackend
+
+# The Islo backend imports its SDK lazily, on first use, so the package is
+# importable without the optional ``islo`` extra; a missing SDK surfaces as an
+# actionable error from the backend instead.
+from airflow.providers.common.ai.sandbox.islo import IsloSandboxBackend
from airflow.providers.common.ai.sandbox.opensandbox import OpenSandboxBackend
from airflow.providers.common.ai.sandbox.sbx import SbxSandboxBackend
__all__ = [
"AttachableSandboxBackend",
"BoatSandboxBackend",
+ "IsloSandboxBackend",
"OpenSandboxBackend",
"ModalSandboxBackend",
"SandboxBackend",
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py
b/providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py
new file mode 100644
index 00000000000..b9c44b60054
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py
@@ -0,0 +1,757 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""islo.dev microVM backend for
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`."""
+
+from __future__ import annotations
+
+import logging
+import math
+import os
+import shlex
+import time
+from contextlib import closing, contextmanager, suppress
+from typing import TYPE_CHECKING, Any, Literal, NoReturn
+
+import httpx
+
+from airflow.providers.common.ai.sandbox.base import (
+ SandboxBackend,
+ SandboxError,
+ SandboxExecResult,
+ SandboxFileTooLargeError,
+ SandboxTerminalError,
+ _check_export_deadline,
+ _export_deadline,
+ _new_sandbox_name,
+ _validate_positive_finite,
+)
+
+# ``islo`` is the optional ``[islo]`` extra and the package exports this
backend
+# eagerly, so the SDK is imported where it is first used, never at module
level.
+if TYPE_CHECKING:
+ from collections.abc import Generator, Iterator
+ from typing import BinaryIO
+
+ from islo import Islo
+ from islo.core.request_options import RequestOptions
+ from islo.types import ExecResponse, ExecResultResponse, SandboxResponse
+
+ from airflow.providers.common.ai.sandbox.base import SandboxSpec
+
+log = logging.getLogger(__name__)
+
+# The vendor types ``status`` as a plain string on a model that allows extra
+# fields, so the vocabulary is open-ended. Track the statuses that mean "not
+# finished yet" instead of the ones that mean "finished": an unrecognised
status
+# then reads as terminal, which surfaces a failure, rather than as
still-running,
+# which would poll to the deadline and cost the agent its sandbox.
+_RUNNING_EXEC_STATUSES = frozenset({"pending", "queued", "starting",
"running"})
+# Sandbox statuses that cannot serve a request. Same open vocabulary, opposite
+# bias: after a missing-file response the sandbox was reachable a moment ago,
so
+# an unknown status reads as usable and only a known-dead one fails the task.
+_UNUSABLE_SANDBOX_STATUSES = frozenset({"stopping", "stopped", "deleting",
"deleted", "error", "failed"})
+_AUTO_RESUME_POLICIES = frozenset({"never", "on_activity"})
+_POLL_INITIAL = 0.2
+_POLL_MAX = 2.0
+_POLL_BACKOFF = 1.5
+# The HTTP timeout of the requests that start and poll a command never drops
+# below this: a small command budget, or the last poll before the deadline,
must
+# not become a one-second request budget that a slow API -- the first call
after
+# an auto-resume, say -- turns into a task failure instead of a command result.
+_POLL_HTTP_TIMEOUT_MIN = 5.0
+# Starting a command is sent without the SDK's retries (see
``_request_options``),
+# so the backend resends it itself, and only after a failure that proves the
+# command never ran.
+_START_RETRIES = 2
+_START_RETRY_DELAY = 1.0
+_FILE_OP_TIMEOUT = 120.0
+# File-transfer responses that say nothing the model can act on: bad
credentials,
+# a rate limit, or an overloaded API. Every other status is checked against the
+# sandbox before it is handed to the model; see ``_raise_file_op_error``.
+_TERMINAL_FILE_OP_STATUSES = frozenset({401, 403, 429})
+_API_MESSAGE_MAX_CHARS = 200
+# Measured against the compute API: each stream is capped at exactly this many
+# bytes with the tail kept, and one ``truncated`` flag covers both streams. The
+# wrapper never asks for more than this per stream, so that flag is not read: a
+# stream long enough to reach the cap is over the budget and marked as cut
anyway.
+_SERVER_STREAM_CAP = 1024 * 1024
+_HELPER_OUTPUT_CAP = _SERVER_STREAM_CAP - 1
+# Runs the agent's command with each stream captured to a scratch file, then
+# emits only the last ``$2`` bytes of each. The tail is what the model needs (a
+# traceback and the exit status live at the end), and bounding inside the guest
+# keeps the transfer and the worker's copy at the caller's budget rather than
+# the server's 1 MiB.
+#
+# ``sh -c`` rather than a login shell: the spec's variables are the process
+# environment of every exec, and ``/etc/profile`` would run after them and win
+# for anything it also exports.
+#
+# Deliberately free of fifos, background jobs and ``wait``: a command that
+# backgrounds a process hands it the capture descriptor, so anything waiting
for
+# end-of-input would block until that process exits -- ``sleep 20 & echo
+# started`` took 20s in a real microVM before this. Redirecting to files means
+# only the foreground command is waited on. The cost is that the scratch file
+# grows with total output, on the sandbox's own ephemeral disk.
+_COMMAND_WRAPPER = """\
+dir="${TMPDIR:-/tmp}/airflow-sandbox-$$"
+mkdir -m 700 "$dir" || exit 70
+trap 'rm -rf "$dir"' EXIT
+trap 'rm -rf "$dir"; exit 143' HUP INT TERM
+sh -c "$1" >"$dir/out" 2>"$dir/err"
+status=$?
+tail -c "$2" <"$dir/out"
+tail -c "$2" <"$dir/err" >&2
+exit "$status"
+"""
+
+
+@contextmanager
+def _translate_islo_errors(operation: str) -> Iterator[None]:
+ try:
+ yield
+ except SandboxError:
+ raise
+ except Exception as e:
+ try:
+ from islo.core.api_error import ApiError
+ except ImportError:
+ raise SandboxTerminalError(
+ 'The Islo SDK is not installed. Install
"apache-airflow-providers-common-ai[islo]".'
+ ) from e
+ if isinstance(e, ApiError):
+ status = f" (HTTP {e.status_code})" if e.status_code is not None
else ""
+ raise SandboxTerminalError(f"Islo could not {operation}{status}.")
from e
+ if isinstance(e, httpx.HTTPStatusError):
+ # The SDK exchanges ISLO_API_KEY for a token with a bare ``httpx``
call
+ # whose failures arrive here rather than as an ``ApiError``.
+ code = e.response.status_code
+ if code in (401, 403):
+ raise SandboxTerminalError(
+ f"Islo rejected ISLO_API_KEY (HTTP {code}) when asked to
{operation}."
+ ) from e
+ raise SandboxTerminalError(f"Islo could not {operation} (HTTP
{code}).") from e
+ raise SandboxTerminalError(f"Islo could not {operation}:
{type(e).__name__}.") from e
+
+
+def _raise_translated(error: Exception, operation: str) -> NoReturn:
+ """Raise ``error`` the way :func:`_translate_islo_errors` would have, for
an error caught elsewhere."""
+ with _translate_islo_errors(operation):
+ raise error
+
+
+def _api_error_message(error: Exception) -> str:
+ """Return the ``message`` of an Islo error body, trimmed for a prompt, or
``""``."""
+ body = getattr(error, "body", None)
+ message = body.get("message") if isinstance(body, dict) else None
+ if not isinstance(message, str):
+ return ""
+ message = message.strip().rstrip(".")
+ if len(message) > _API_MESSAGE_MAX_CHARS:
+ message = message[:_API_MESSAGE_MAX_CHARS] + "..."
+ return message
+
+
+def _is_transient_error(error: Exception) -> bool:
+ """Whether a failed call says nothing about the command: a 5xx, a 429, or
no response at all."""
+ from islo.core.api_error import ApiError
+
+ if isinstance(error, ApiError):
+ return error.status_code is None or error.status_code == 429 or
error.status_code >= 500
+ if isinstance(error, httpx.HTTPStatusError):
+ # A token refresh that failed the same way, before the call itself was
sent.
+ code = error.response.status_code
+ return code == 429 or code >= 500
+ return isinstance(error, httpx.TransportError)
+
+
+def _is_undelivered_start(error: Exception) -> bool:
+ """
+ Whether a failed start proves the command never ran.
+
+ That is: no connection was made, a 429 refused it, or the token exchange
that
+ runs before it failed with a 429 or 5xx.
+ """
+ from islo.core.api_error import ApiError
+
+ if isinstance(error, ApiError):
+ return error.status_code == 429
+ if isinstance(error, httpx.HTTPStatusError):
+ # The token refresh runs before the start is sent, so its 429 or 5xx
means
+ # the command was never asked for. A 401 or 403 stays terminal.
+ code = error.response.status_code
+ return code == 429 or code >= 500
+ return isinstance(error, httpx.ConnectError)
+
+
+def _bound_result_stream(text: str, max_bytes: int) -> tuple[str, bool]:
+ """
+ Trim one stream to ``max_bytes``, keeping the tail, and report whether
bytes were dropped.
+
+ The sandbox is asked for one byte more than the budget, so a stream that
+ comes back over budget is the signal that the guest had more to give.
+ """
+ encoded = text.encode("utf-8", errors="surrogatepass")
+ truncated = False
+ if len(encoded) > max_bytes:
+ encoded = encoded[-max_bytes:]
+ truncated = True
+ # A byte-aligned cut usually lands mid-record, so drop the leading
partial
+ # line -- but only while half the window survives, as Modal's
``_drain``
+ # does. One line longer than the budget ends in its only newline, and
+ # dropping through it would leave nothing, which the model reads as
"(no
+ # output)"; a partial line kept instead is still marked as cut.
+ newline = encoded.find(b"\n")
+ if newline != -1 and len(encoded) - (newline + 1) >= max_bytes // 2:
+ encoded = encoded[newline + 1 :]
+ return encoded.decode("utf-8", errors="replace"), truncated
+
+
+class IsloSandboxBackend(SandboxBackend):
+ """
+ Sandbox backend that runs agent commands in an `islo.dev
<https://islo.dev>`__ microVM.
+
+ .. note::
+
+ Experimental: this can change or be removed in a minor release of this
provider.
+ See :ref:`howto/stability`.
+
+ Islo is a hosted API with no local daemon or host-virtualization
requirement,
+ so this backend works from an Airflow worker running in a container.
+
+ **Credentials are ambient.** The SDK reads ``ISLO_API_KEY``, and optionally
+ ``ISLO_BASE_URL`` and ``ISLO_COMPUTE_URL``, from the worker environment on
first
+ use; an unset ``ISLO_API_KEY`` fails the task at that point. Modal reads a
+ ``modal`` connection first, but that connection type is owned by the Modal
+ provider; an ``islo`` connection type belongs in a future Islo provider,
not in
+ this one.
+
+ File reads, writes and exports move file contents through Islo's native
+ streaming APIs. Everything else runs through a shell wrapper in the
sandbox,
+ so the image needs ``sh``, ``mkdir`` and ``rm`` for the wrapper, ``tail``
to
+ bound command output, ``dirname`` to create a written file's parent
+ directory, ``stat`` to size an over-budget read and to check a file before
it
+ is exported, ``realpath`` to resolve a relative export path, and GNU
``find``
+ for directory listings. The server default
+ image and any Debian or Ubuntu based image provide them. Each command's
+ output is captured to a scratch file in the sandbox and only its last
+ ``max_output_bytes`` are returned, so the worker sees a bounded tail while
+ the sandbox's own ephemeral disk absorbs the rest.
+
+ Islo sets ``PATH`` for every command itself and drops a ``PATH`` given at
+ creation, so a spec that names it is refused rather than silently ignored.
+ ``SandboxSpec.owner`` is refused for the same reason: this backend keeps no
+ per-sandbox metadata, so a sandbox created here cannot be attached to
later.
+
+ :param image: Sandbox image. ``None`` (default) uses the server default.
+ :param vcpus: Number of virtual CPUs. ``None`` uses the server default.
+ :param memory_mb: Memory in MB. ``None`` uses the server default.
+ :param pause_after_idle: Seconds without a command or file operation after
+ which the server pauses the microVM and releases its compute. ``None``
+ disables it. Default ``600``.
+ :param auto_resume: ``"on_activity"`` (default) resumes a paused sandbox on
+ the next command or file operation; ``"never"`` leaves it paused, and
the
+ backend then treats a paused sandbox as unusable.
+ :param delete_after: Seconds after *creation* at which the server deletes
the
+ sandbox whether or not it is in use. ``None`` disables it. Default
+ ``86400``.
+ """
+
+ name = "islo"
+
+ def __init__(
+ self,
+ *,
+ image: str | None = None,
+ vcpus: int | None = None,
+ memory_mb: int | None = None,
+ pause_after_idle: int | None = 600,
+ auto_resume: Literal["never", "on_activity"] = "on_activity",
+ delete_after: int | None = 86400,
+ ) -> None:
+ if pause_after_idle is not None:
+ _validate_positive_finite(pause_after_idle, "pause_after_idle")
+ if delete_after is not None:
+ _validate_positive_finite(delete_after, "delete_after")
+ if auto_resume not in _AUTO_RESUME_POLICIES:
+ raise ValueError(
+ f"auto_resume must be one of {sorted(_AUTO_RESUME_POLICIES)},
got {auto_resume!r}."
+ )
+ if vcpus is not None:
+ _validate_positive_finite(vcpus, "vcpus")
+ if memory_mb is not None:
+ _validate_positive_finite(memory_mb, "memory_mb")
+ if image == "":
+ raise ValueError("image must not be empty.")
+ self._image = image
+ self._vcpus = vcpus
+ self._memory_mb = memory_mb
+ self._pause_after_idle = pause_after_idle
+ self._auto_resume = auto_resume
+ self._delete_after = delete_after
+ self._client: Islo | None = None
+
+ def _get_client(self) -> Islo:
+ if self._client is not None:
+ return self._client
+ with _translate_islo_errors("initialize its client"):
+ from islo import Islo
+
+ # Without a key the SDK builds a client that sends no credentials,
and
+ # the first sign of it would be a bare HTTP error from create.
+ if not os.environ.get("ISLO_API_KEY"):
+ raise SandboxTerminalError(
+ "ISLO_API_KEY is not set in the worker environment; the
Islo backend reads its "
+ "credentials only from there."
+ )
+ self._client = Islo()
+ return self._client
+
+ @staticmethod
+ def _request_options(
+ *, timeout: float, chunk_size: int | None = None, max_retries: int |
None = None
+ ) -> RequestOptions:
+ # ``max_retries`` is left to the SDK's default unless asked for:
passing
+ # 0 switches off its retries of dropped connections and of 5xx, 408,
409
+ # and 429 answers. Starting a command is the exception: that POST
carries no
+ # idempotency key, so a resend after a lost response or a 5xx would
run the
+ # agent's command a second time.
+ options: RequestOptions = {"timeout_in_seconds": max(1,
math.ceil(timeout))}
+ if chunk_size is not None:
+ options["chunk_size"] = chunk_size
+ if max_retries is not None:
+ options["max_retries"] = max_retries
+ return options
+
+ def _ensure_sandbox_usable(self, info: SandboxResponse) -> None:
+ status = info.status
+ unusable = info.deleted_at is not None or status in
_UNUSABLE_SANDBOX_STATUSES
+ if status == "paused" and self._auto_resume != "on_activity":
+ unusable = True
+ if unusable:
+ raise SandboxTerminalError(
+ f"Islo sandbox {info.name!r} cannot serve requests
(status={status!r})."
+ )
+
+ @staticmethod
+ def _check_spec(spec: SandboxSpec | None) -> None:
+ """Refuse a spec this backend cannot carry faithfully, before anything
is provisioned."""
+ if spec is None:
+ return
+ if spec.owner is not None:
+ # An owner exists so that a later task can attach to the sandbox,
and the
+ # ownership rules live in per-sandbox metadata this backend keeps
none of,
+ # so recording one would promise an attach that cannot be checked.
+ raise SandboxTerminalError(
+ "SandboxSpec names an owner, but this backend keeps no
per-sandbox metadata the "
+ "ownership rules could be read back from, so a sandbox created
here cannot be attached "
+ "to from another task. Drop owner, or provision the sandbox on
a backend that supports "
+ "attaching, such as ModalSandboxBackend."
+ )
+ if spec.allow_egress_to:
+ raise SandboxTerminalError(
+ "The Islo backend cannot apply a per-domain egress allowlist;
it can only turn "
+ "outbound access on or off. Drop allow_egress_to, or use a
backend with "
+ "per-domain network rules."
+ )
+ if spec.allow_egress_to_cidrs:
+ raise SandboxTerminalError(
+ "SandboxSpec names allow_egress_to_cidrs, which the Islo
backend cannot enforce: "
+ "it can only turn outbound access on or off. Drop
allow_egress_to_cidrs, or use "
+ "a backend with an address-layer allowlist."
+ )
+ if spec.env and "PATH" in spec.env:
+ raise SandboxTerminalError(
+ "Islo sets PATH for every command itself and drops a PATH
given at creation; "
+ "remove PATH from SandboxSpec.env."
+ )
+
+ def create(self, *, spec: SandboxSpec | None = None) -> str:
+ self._check_spec(spec)
+ with _translate_islo_errors("create a sandbox"):
+ from islo.types import AutoResumePolicy, LifecyclePolicy
+
+ kwargs: dict[str, Any] = {
+ "internet_enabled": False if spec is None else not
spec.block_network,
+ "lifecycle": LifecyclePolicy(
+ pause_after_idle=self._pause_after_idle,
+ auto_resume=AutoResumePolicy(self._auto_resume),
+ delete_after=self._delete_after,
+ ),
+ }
+ if self._image is not None:
+ kwargs["image"] = self._image
+ if self._vcpus is not None:
+ kwargs["vcpus"] = self._vcpus
+ if self._memory_mb is not None:
+ kwargs["memory_mb"] = self._memory_mb
+ if spec is not None and spec.env:
+ # Verified against a live microVM: variables set here are the
+ # process environment of every later exec.
+ kwargs["env"] = dict(spec.env)
+ # Bind the name before the call. If creation fails after the server
+ # provisioned the microVM -- a response timeout, a reset, a 5xx --
+ # this is the only handle that can still delete it, and without it
+ # the leak is neither cleanable nor traceable to a run.
+ name = _new_sandbox_name()
+ try:
+ sandbox: SandboxResponse =
self._get_client().sandboxes.create_sandbox(
+ name=name,
+
request_options=self._request_options(timeout=_FILE_OP_TIMEOUT),
+ **kwargs,
+ )
+ except BaseException:
+ with suppress(Exception):
+ self.destroy(name)
+ raise
+ try:
+ self._ensure_sandbox_usable(sandbox)
+ except SandboxTerminalError:
+ with suppress(Exception):
+ self.destroy(name)
+ raise
+ return sandbox.name
+
+ def _await_exec(self, sandbox: str, exec_id: str, *, deadline: float) ->
ExecResultResponse | None:
+ client = self._get_client()
+ interval = _POLL_INITIAL
+ last_error: Exception | None = None
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ try:
+ result: ExecResultResponse = client.sandboxes.get_exec_result(
+ sandbox,
+ exec_id,
+
request_options=self._request_options(timeout=max(_POLL_HTTP_TIMEOUT_MIN,
remaining)),
+ )
+ except Exception as e:
+ if not _is_transient_error(e):
+ _raise_translated(e, "poll a sandbox command")
+ # One failed poll says nothing about the command; the deadline
decides.
+ last_error = e
+ else:
+ last_error = None
+ if result.status not in _RUNNING_EXEC_STATUSES:
+ return result
+ time.sleep(min(interval, max(0.0, deadline - time.monotonic())))
+ interval = min(interval * _POLL_BACKOFF, _POLL_MAX)
+ if last_error is not None:
+ # Nothing was heard after the last failure, so "timed out" would
be a guess.
+ _raise_translated(last_error, "poll a sandbox command")
+ return None
+
+ def _destroy_after_timeout(self, sandbox: str) -> None:
+ try:
+ self.destroy(sandbox)
+ except SandboxError:
+ # Warn rather than fail the task: the command merely ran long, and
+ # failing here would turn a timeout the model can react to into a
+ # task failure over a transient error. The warning says whether the
+ # lifecycle policy will reclaim the microVM, since with
+ # ``delete_after=None`` nothing will.
+ if self._delete_after is not None:
+ reclaim = f"its delete_after policy removes it
{self._delete_after}s after creation"
+ else:
+ reclaim = "delete_after is disabled, so it persists until
deleted by hand"
+ log.warning(
+ "Timed out running a command in Islo sandbox %s and could not
confirm its deletion; %s.",
+ sandbox,
+ reclaim,
+ exc_info=True,
+ )
+
+ def run_command(
+ self, sandbox: str, command: str, *, timeout: float, max_output_bytes:
int
+ ) -> SandboxExecResult:
+ _validate_positive_finite(timeout, "timeout")
+ _validate_positive_finite(max_output_bytes, "max_output_bytes")
+ client = self._get_client()
+ # The server returns at most _SERVER_STREAM_CAP bytes per stream, so a
+ # larger budget cannot be honoured and is clamped below it.
+ budget = min(max_output_bytes, _SERVER_STREAM_CAP - 1)
+ attempt = 0
+ while True:
+ try:
+ response: ExecResponse = client.sandboxes.exec_in_sandbox(
+ sandbox,
+ # One byte over the budget, so a stream that comes back
over it
+ # is proof the guest had more to give.
+ command=[
+ "sh",
+ "-c",
+ _COMMAND_WRAPPER,
+ "airflow-sandbox",
+ command,
+ str(budget + 1),
+ ],
+ timeout_secs=max(1, math.ceil(timeout)),
+ request_options=self._request_options(
+ timeout=max(_POLL_HTTP_TIMEOUT_MIN, timeout),
max_retries=0
+ ),
+ )
+ break
+ except Exception as e:
+ if attempt >= _START_RETRIES or not _is_undelivered_start(e):
+ _raise_translated(e, "start a sandbox command")
+ time.sleep(_START_RETRY_DELAY * 2**attempt)
+ attempt += 1
+ # Timed from the start's answer, so a slow start cannot spend the
+ # command's budget and leave it destroyed without a single poll.
+ deadline = time.monotonic() + timeout
+ result = self._await_exec(sandbox, response.exec_id, deadline=deadline)
+ if result is None:
+ self._destroy_after_timeout(sandbox)
+ return SandboxExecResult(
+ exit_code=-1, stdout="", stderr="", timed_out=True,
sandbox_terminated=True
+ )
+
+ stdout, out_truncated = _bound_result_stream(result.stdout, budget)
+ stderr, err_truncated = _bound_result_stream(result.stderr, budget)
+ if result.status == "timeout":
+ self._destroy_after_timeout(sandbox)
+ return SandboxExecResult(
+ exit_code=-1,
+ stdout=stdout,
+ stderr=stderr,
+ timed_out=True,
+ stdout_truncated=out_truncated,
+ stderr_truncated=err_truncated,
+ sandbox_terminated=True,
+ )
+ return SandboxExecResult(
+ exit_code=result.exit_code if result.exit_code is not None else -1,
+ stdout=stdout,
+ stderr=stderr,
+ stdout_truncated=out_truncated,
+ stderr_truncated=err_truncated,
+ )
+
+ def _run_helper(self, sandbox: str, script: str, *, operation: str) ->
SandboxExecResult:
+ result = self.run_command(
+ sandbox, script, timeout=_FILE_OP_TIMEOUT,
max_output_bytes=_HELPER_OUTPUT_CAP
+ )
+ if result.timed_out or result.sandbox_terminated:
+ raise SandboxTerminalError(f"The sandbox was destroyed after it
timed out while {operation}.")
+ if result.exit_code:
+ raise SandboxError(result.stderr.strip() or f"Could not
{operation}.")
+ return result
+
+ def _raise_file_op_error(
+ self, sandbox: str, path: str, error: Exception, *, operation: str,
hint: str = ""
+ ) -> NoReturn:
+ """
+ Raise a failed file transfer as a prompt for the model when the
sandbox explains it.
+
+ Measured against the file APIs: a missing file and a directory are
both a
+ 404, a relative path is a 400 whose message names the problem, and
writing
+ onto a directory or under a pseudo-filesystem such as ``/proc`` or
``/sys``
+ is a bare 500. Each of those is the model's to fix. A gone or stopped
+ sandbox fails file calls too, and no tool call can fix that, so the
sandbox
+ is checked before anything reaches the model. No response at all, bad
+ credentials, a rate limit and a 5xx above 500 say nothing about the
path and
+ fail the task.
+ """
+ from islo.core.api_error import ApiError
+
+ status = error.status_code if isinstance(error, ApiError) else None
+ if status is None or status in _TERMINAL_FILE_OP_STATUSES or status >
500:
+ _raise_translated(error, f"{operation} a sandbox file")
+ with _translate_islo_errors(f"check a sandbox after a failed file
{operation}"):
+ info: SandboxResponse = self._get_client().sandboxes.get_sandbox(
+ sandbox,
request_options=self._request_options(timeout=_FILE_OP_TIMEOUT)
+ )
+ self._ensure_sandbox_usable(info)
+ if status == 404:
+ raise SandboxError(
+ f"{path!r} does not exist in the sandbox, or is not a regular
file."
+ ) from error
+ message = f"Could not {operation} {path!r} (HTTP {status})"
+ detail = _api_error_message(error) if status < 500 else ""
+ if detail:
+ message += f": {detail}"
+ message += "."
+ if hint and status >= 500:
+ message += f" {hint}"
+ raise SandboxError(message) from error
+
+ def _get_file_size(self, sandbox: str, path: str) -> int | None:
+ """Ask the guest for the file's size; ``None`` when it cannot say."""
+ try:
+ result = self._run_helper(
+ sandbox, f"stat -Lc %s -- {shlex.quote(path)}",
operation=f"size {path!r}"
+ )
+ return int(result.stdout.strip())
+ except SandboxTerminalError:
+ raise
+ except (SandboxError, ValueError):
+ return None
+
+ def _download(
+ self, sandbox: str, path: str, *, max_bytes: int, operation: str
+ ) -> Generator[bytes, None, None]:
+ """
+ Yield a sandbox file in chunks, raising a failed download through
:meth:`_raise_file_op_error`.
+
+ A generator, so a failure in whatever the caller does with a chunk is
raised in
+ the caller and never mistaken for a failed download.
+ """
+ client = self._get_client()
+ chunks = None
+ try:
+ chunks = client.sandboxes.download_file(
+ sandbox,
+ path=path,
+ request_options=self._request_options(
+ timeout=_FILE_OP_TIMEOUT, chunk_size=min(65536, max_bytes
+ 1)
+ ),
+ )
+ yield from chunks
+ except Exception as e:
+ self._raise_file_op_error(sandbox, path, e, operation=operation)
+ finally:
+ close = getattr(chunks, "close", None)
+ if close is not None:
+ with suppress(Exception):
+ close()
+
+ def read_file(self, sandbox: str, path: str, *, max_bytes: int) -> bytes:
+ _validate_positive_finite(max_bytes, "max_bytes")
+ data = bytearray()
+ with closing(self._download(sandbox, path, max_bytes=max_bytes,
operation="read")) as chunks:
+ for chunk in chunks:
+ data.extend(chunk[: max_bytes + 1 - len(data)])
+ if len(data) > max_bytes:
+ break
+ if len(data) > max_bytes:
+ # The download API has no size endpoint, so the guest's own stat
+ # supplies the number the model plans around. Without it, say only
+ # what is known rather than report the budget back as the size.
+ size = self._get_file_size(sandbox, path)
+ if size is None:
+ raise SandboxError(
+ f"{path!r} is larger than the {max_bytes} byte read limit.
Read just the part you "
+ "need with a shell command instead (e.g. head, tail, sed
-n, or grep)."
+ )
+ raise SandboxFileTooLargeError(path, max(size, len(data)),
max_bytes)
+ return bytes(data)
+
+ def export_file(self, sandbox: str, path: str, dest: BinaryIO, *,
max_bytes: int) -> int:
+ """
+ Override: stream the file through Islo's download API into ``dest``.
+
+ The default reads 4 MiB slices through ``run_command``, which here
returns at
+ most 1 MiB per stream. The guest still runs the default's checks
first, so a
+ refusal means what it does on every backend and the size it reports
catches
+ a file that is still being written.
+ """
+ _validate_positive_finite(max_bytes, "max_bytes")
+ deadline = _export_deadline(max_bytes)
+ quoted = shlex.quote(path)
+ check = self.run_command(
+ sandbox,
+ f"{self._export_checks(quoted, max_bytes)}
{self._print_export_size()}",
+ timeout=_FILE_OP_TIMEOUT,
+ max_output_bytes=_HELPER_OUTPUT_CAP,
+ )
+ self._raise_for_export_status(path, check, max_bytes)
+ size = self._parse_export_size(check.stdout)
+ source = path
+ if not path.startswith("/"):
+ # An export path may be relative to the sandbox's working
directory, as it
+ # is for the checks above, but the download API answers a relative
path
+ # with a 400. The export runs after the agent has finished, so
that 400
+ # would fail the task rather than reach the model: resolve the
path in
+ # the guest instead, so the checks and the download see one file.
+ # Only realpath's own newline is dropped: whitespace at the end of
a
+ # file name is part of the name.
+ source = self._run_helper(
+ sandbox, f"realpath -e -- {quoted}", operation=f"resolve
{path!r}"
+ ).stdout.removesuffix("\n")
+ written = 0
+ with closing(self._download(sandbox, source, max_bytes=max_bytes,
operation="export")) as chunks:
+ for chunk in chunks:
+ _check_export_deadline(path, deadline, max_bytes)
+ written += len(chunk)
+ if written > max_bytes:
+ raise SandboxFileTooLargeError(path, written, max_bytes)
+ dest.write(chunk)
+ self._check_export_size(path, expected=size, written=written)
+ return written
+
+ def write_file(self, sandbox: str, path: str, content: bytes) -> None:
+ quoted = shlex.quote(path)
+ self._run_helper(
+ sandbox,
+ f'mkdir -p -- "$(dirname -- {quoted})"',
+ operation=f"create the parent directory for {path!r}",
+ )
+ client = self._get_client()
+ try:
+ client.sandboxes.upload_file(
+ sandbox,
+ path=path,
+ file=("upload", content, "application/octet-stream"),
+
request_options=self._request_options(timeout=_FILE_OP_TIMEOUT),
+ )
+ except Exception as e:
+ self._raise_file_op_error(
+ sandbox,
+ path,
+ e,
+ operation="write",
+ hint=(
+ "Islo answers this way when the path is a directory or
under a"
+ " pseudo-filesystem such as /proc or /sys."
+ ),
+ )
+
+ def list_directory(self, sandbox: str, path: str) -> list[tuple[str,
bool]]:
+ quoted = shlex.quote(path)
+ result = self._run_helper(
+ sandbox,
+ f"find -- {quoted} -maxdepth 1 -mindepth 1 -printf '%y %f\\0'",
+ operation=f"list {path!r}",
+ )
+ records = result.stdout.split("\0")
+ if result.stdout_truncated and records:
+ # Both the wrapper and the server keep the tail, so the cut is at
+ # the head: drop the leading record rather than report a mangled
+ # entry name the model cannot open.
+ records = records[1:]
+ entries: list[tuple[str, bool]] = []
+ for record in records:
+ if not record:
+ continue
+ kind, _, name = record.partition(" ")
+ if name:
+ entries.append((name, kind == "d"))
+ return entries
+
+ def destroy(self, sandbox: str) -> None:
+ client = self._get_client()
+ from islo.errors import NotFoundError
+
+ try:
+ client.sandboxes.delete_sandbox(
+ sandbox_name=sandbox,
+ # Deletion is idempotent, and it is the one call whose failure
+ # strands a microVM, so make the SDK's retries explicit here.
+
request_options=self._request_options(timeout=_FILE_OP_TIMEOUT, max_retries=2),
+ )
+ except NotFoundError:
+ return
+ except Exception as e:
+ _raise_translated(e, "delete a sandbox")
diff --git a/providers/common/ai/tests/system/common/ai/README.md
b/providers/common/ai/tests/system/common/ai/README.md
index 8d6484ea758..bf3a225a59b 100644
--- a/providers/common/ai/tests/system/common/ai/README.md
+++ b/providers/common/ai/tests/system/common/ai/README.md
@@ -52,6 +52,18 @@ modal token new
pytest --system
providers/common/ai/tests/system/common/ai/example_sandbox_toolset_modal.py
```
+## Islo
+
+Install the Islo extra and export a short-lived API key into the task process:
+
+```console
+pip install "apache-airflow-providers-common-ai[islo]"
+export ISLO_API_KEY="..."
+pytest --system
providers/common/ai/tests/system/common/ai/example_sandbox_toolset_islo.py
+```
+
+The test requests the default no-egress policy and a 15-minute server-side TTL.
+
## OpenSandbox
Install the OpenSandbox extra, point the SDK at a running server, and run:
diff --git
a/providers/common/ai/tests/system/common/ai/example_sandbox_toolset_islo.py
b/providers/common/ai/tests/system/common/ai/example_sandbox_toolset_islo.py
new file mode 100644
index 00000000000..30f2bb89cad
--- /dev/null
+++ b/providers/common/ai/tests/system/common/ai/example_sandbox_toolset_islo.py
@@ -0,0 +1,134 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""End-to-end system test for SandboxToolset with Islo."""
+
+from __future__ import annotations
+
+import os
+from datetime import UTC, datetime
+
+from airflow.providers.common.compat.sdk import dag as airflow_dag, task
+
+ENV_ID = os.environ.get("SYSTEM_TESTS_ENV_ID")
+DAG_ID = f"common_ai_sandbox_toolset_islo_{ENV_ID}" if ENV_ID else
"common_ai_sandbox_toolset_islo"
+
+MARKER = "boundary-ok"
+STATE_PATH = "/tmp/airflow_sandbox_e2e"
+
+
+@airflow_dag(
+ dag_id=DAG_ID,
+ schedule="@once",
+ start_date=datetime(2024, 1, 1, tzinfo=UTC),
+ catchup=False,
+ tags=["common.ai", "sandbox", "islo", "system_test"],
+)
+def example_sandbox_toolset_islo():
+ @task
+ def run_sandbox_agent() -> str:
+ from pydantic_ai import Agent
+ from pydantic_ai.messages import ModelMessage, ModelResponse,
TextPart, ToolCallPart
+ from pydantic_ai.models.function import AgentInfo, FunctionModel
+
+ from airflow.providers.common.ai.sandbox import IsloSandboxBackend
+ from airflow.providers.common.ai.toolsets import SandboxToolset
+
+ def model_function(messages: list[ModelMessage], _info: AgentInfo) ->
ModelResponse:
+ returns = [
+ part.content
+ for message in messages
+ for part in message.parts
+ if part.part_kind == "tool-return"
+ ]
+
+ if not returns:
+ return ModelResponse(
+ parts=[
+ ToolCallPart(
+ tool_name="write_file",
+ args={"path": STATE_PATH, "content": MARKER},
+ tool_call_id="write",
+ )
+ ]
+ )
+ if "Wrote" not in str(returns[0]):
+ raise RuntimeError(f"Unexpected write_file result:
{returns[0]!r}")
+
+ if len(returns) == 1:
+ return ModelResponse(
+ parts=[
+ ToolCallPart(
+ tool_name="run_command",
+ args={"command": f"cat {STATE_PATH} && echo $((6 *
7))"},
+ tool_call_id="shell",
+ )
+ ]
+ )
+ shell_out = str(returns[1])
+ if MARKER not in shell_out or "42" not in shell_out:
+ raise RuntimeError(f"Unexpected run_command result:
{shell_out!r}")
+
+ if len(returns) == 2:
+ return ModelResponse(
+ parts=[
+ ToolCallPart(tool_name="read_file", args={"path":
STATE_PATH}, tool_call_id="read")
+ ]
+ )
+ if MARKER not in str(returns[2]):
+ raise RuntimeError(f"Unexpected read_file result:
{returns[2]!r}")
+
+ if len(returns) == 3:
+ return ModelResponse(
+ parts=[
+ ToolCallPart(
+ tool_name="run_command",
+ args={"command": "echo to-stderr >&2; exit 3"},
+ tool_call_id="fail",
+ )
+ ]
+ )
+ failed = str(returns[3])
+ if "[exit code: 3]" not in failed or "to-stderr" not in failed:
+ raise RuntimeError(f"Unexpected failure result: {failed!r}")
+
+ if len(returns) == 4:
+ return ModelResponse(
+ parts=[ToolCallPart(tool_name="list_directory",
args={"path": "/tmp"}, tool_call_id="ls")]
+ )
+ if "airflow_sandbox_e2e" not in str(returns[4]):
+ raise RuntimeError(f"Unexpected listing: {returns[4]!r}")
+
+ return ModelResponse(parts=[TextPart(content="sandbox boundary e2e
passed")])
+
+ agent = Agent(
+ FunctionModel(model_function),
+ instructions="Use the sandbox tools as requested.",
+ toolsets=[SandboxToolset(IsloSandboxBackend(delete_after=900))],
+ )
+ result = agent.run_sync("Run the sandbox boundary system test.")
+ if result.output != "sandbox boundary e2e passed":
+ raise RuntimeError(f"Unexpected agent output: {result.output!r}")
+ return result.output
+
+ run_sandbox_agent()
+
+
+dag = example_sandbox_toolset_islo()
+
+from tests_common.test_utils.system_tests import get_test_run # noqa: E402
+
+test_run = get_test_run(dag)
diff --git a/providers/common/ai/tests/unit/common/ai/sandbox/test_islo.py
b/providers/common/ai/tests/unit/common/ai/sandbox/test_islo.py
new file mode 100644
index 00000000000..285681a6c3b
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_islo.py
@@ -0,0 +1,1276 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import builtins
+import io
+import os
+import signal
+import subprocess
+import time
+from contextlib import suppress
+from pathlib import Path
+from types import SimpleNamespace
+from unittest import mock
+
+import pytest
+
+pytest.importorskip("islo")
+
+import httpx
+from islo import Islo
+from islo.core.api_error import ApiError
+from islo.errors import NotFoundError
+from islo.sandboxes.client import SandboxesClient
+
+from airflow.providers.common.ai.sandbox.base import (
+ SandboxError,
+ SandboxExecResult,
+ SandboxFileTooLargeError,
+ SandboxSpec,
+ SandboxTerminalError,
+)
+from airflow.providers.common.ai.sandbox.islo import (
+ _COMMAND_WRAPPER,
+ _SERVER_STREAM_CAP,
+ IsloSandboxBackend,
+ _bound_result_stream,
+)
+
+_MODULE = "airflow.providers.common.ai.sandbox.islo"
+_ISLO_PATH = "islo.Islo"
+
+
+def _exec_result(status="completed", exit_code=0, stdout="", stderr="",
truncated=False):
+ return SimpleNamespace(
+ status=status, exit_code=exit_code, stdout=stdout, stderr=stderr,
truncated=truncated
+ )
+
+
+def _sandbox_info(name="box-1", status="running", deleted_at=None):
+ return SimpleNamespace(name=name, status=status, deleted_at=deleted_at)
+
+
+def _token_error(status_code):
+ """How the SDK's ``ISLO_API_KEY`` token exchange fails: a bare ``httpx``
error, not an ``ApiError``."""
+ request = httpx.Request("POST", "https://api.islo.dev/auth/token")
+ return httpx.HTTPStatusError(
+ "token exchange failed", request=request,
response=httpx.Response(status_code, request=request)
+ )
+
+
+def _export_check(size):
+ """What the guest's export check prints for a regular file of ``size``
bytes."""
+ return _exec_result(stdout=f"\nairflow-export-size:{size}\n")
+
+
+def _backend_with_client(**kwargs) -> tuple[IsloSandboxBackend,
mock.MagicMock]:
+ backend = IsloSandboxBackend(**kwargs)
+ client = mock.MagicMock(spec=["sandboxes"])
+ client.sandboxes = mock.create_autospec(SandboxesClient, instance=True)
+ client.sandboxes.exec_in_sandbox.return_value =
SimpleNamespace(exec_id="exec-1")
+ client.sandboxes.create_sandbox.return_value = _sandbox_info()
+ client.sandboxes.get_sandbox.return_value = _sandbox_info()
+ client.sandboxes.get_exec_result.return_value = _exec_result()
+ backend._client = client
+ return backend, client
+
+
+class TestCredentials:
+ @mock.patch(_ISLO_PATH, autospec=True)
+ def test_client_comes_from_the_sdk_environment(self, islo, monkeypatch):
+ monkeypatch.setenv("ISLO_API_KEY", "ak_test")
+
+ client = IsloSandboxBackend()._get_client()
+
+ islo.assert_called_once_with()
+ assert client is islo.return_value
+
+ @mock.patch(_ISLO_PATH, autospec=True)
+ def test_client_is_resolved_once_and_cached(self, islo, monkeypatch):
+ monkeypatch.setenv("ISLO_API_KEY", "ak_test")
+ backend = IsloSandboxBackend()
+
+ backend._get_client()
+ backend._get_client()
+
+ islo.assert_called_once_with()
+
+ @pytest.mark.parametrize("value", [None, ""], ids=["unset", "empty"])
+ @mock.patch(_ISLO_PATH, autospec=True)
+ def test_a_missing_api_key_is_terminal_and_names_the_variable(self, islo,
monkeypatch, value):
+ if value is None:
+ monkeypatch.delenv("ISLO_API_KEY", raising=False)
+ else:
+ monkeypatch.setenv("ISLO_API_KEY", value)
+
+ with pytest.raises(SandboxTerminalError, match="ISLO_API_KEY"):
+ IsloSandboxBackend()._get_client()
+
+ islo.assert_not_called()
+
+ def test_a_rejected_api_key_names_the_variable_and_status(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.create_sandbox.side_effect = _token_error(401)
+
+ with pytest.raises(SandboxTerminalError, match=r"Islo rejected
ISLO_API_KEY \(HTTP 401\)"):
+ backend.create()
+
+ def test_missing_sdk_error_is_actionable(self):
+ real_import = builtins.__import__
+
+ def blocked_import(name, *args, **kwargs):
+ if name.startswith("islo"):
+ raise ImportError("blocked for test")
+ return real_import(name, *args, **kwargs)
+
+ backend = IsloSandboxBackend()
+ with mock.patch("builtins.__import__", side_effect=blocked_import):
+ with pytest.raises(SandboxTerminalError, match=r"\[islo\]"):
+ backend.create()
+
+
[email protected](
+ ("kwargs", "message"),
+ [
+ ({"image": ""}, "image"),
+ ({"vcpus": 0}, "vcpus"),
+ ({"memory_mb": 0}, "memory_mb"),
+ ({"delete_after": 0}, "delete_after"),
+ ({"pause_after_idle": 0}, "pause_after_idle"),
+ ({"auto_resume": "sometimes"}, "auto_resume"),
+ ],
+)
+def test_constructor_rejects_invalid_values(kwargs, message):
+ with pytest.raises(ValueError, match=message):
+ IsloSandboxBackend(**kwargs)
+
+
+class TestCreate:
+ @pytest.mark.parametrize(
+ ("spec", "match"),
+ [
+ (SandboxSpec(owner="example_dag/manual__1"), "owner"),
+ (SandboxSpec(allow_egress_to=["example.com"]), "per-domain egress
allowlist"),
+ # internet_enabled is all-or-nothing, so honouring
block_network=True alone
+ # would silently drop the ranges the Dag author asked to reach.
+ (SandboxSpec(allow_egress_to_cidrs=["203.0.113.0/24"]),
"allow_egress_to_cidrs"),
+ (SandboxSpec(env={"PATH": "/opt/tool/bin"}), "PATH"),
+ ],
+ ids=["owner", "domain-allowlist", "cidr-allowlist", "path"],
+ )
+ def test_refuses_a_spec_it_cannot_carry_before_provisioning(self, spec,
match):
+ backend, client = _backend_with_client()
+
+ with pytest.raises(SandboxTerminalError, match=match):
+ backend.create(spec=spec)
+
+ client.sandboxes.create_sandbox.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("spec", "expected"),
+ [
+ (None, False),
+ (SandboxSpec(), False),
+ (SandboxSpec(block_network=True), False),
+ (SandboxSpec(block_network=False), True),
+ ],
+ )
+ def test_block_network_maps_to_internet_enabled(self, spec, expected):
+ backend, client = _backend_with_client()
+
+ backend.create(spec=spec)
+
+ assert
client.sandboxes.create_sandbox.call_args.kwargs["internet_enabled"] is expected
+
+ def test_spec_and_sizing_are_passed_at_creation(self):
+ backend, client = _backend_with_client(
+ image="python",
+ vcpus=2,
+ memory_mb=1024,
+ pause_after_idle=300,
+ auto_resume="never",
+ delete_after=120,
+ )
+
+ name = backend.create(spec=SandboxSpec(env={"TOKEN": "value"}))
+
+ assert name == "box-1"
+ kwargs = client.sandboxes.create_sandbox.call_args.kwargs
+ assert kwargs["image"] == "python"
+ assert kwargs["vcpus"] == 2
+ assert kwargs["memory_mb"] == 1024
+ assert kwargs["env"] == {"TOKEN": "value"}
+ assert kwargs["lifecycle"].pause_after_idle == 300
+ assert kwargs["lifecycle"].auto_resume == "never"
+ assert kwargs["lifecycle"].delete_after == 120
+ assert kwargs["request_options"] == {"timeout_in_seconds": 120}
+
+ def
test_default_lifecycle_pauses_idle_sandboxes_and_deletes_after_a_day(self):
+ backend, client = _backend_with_client()
+
+ backend.create()
+
+ lifecycle =
client.sandboxes.create_sandbox.call_args.kwargs["lifecycle"]
+ assert lifecycle.pause_after_idle == 600
+ assert lifecycle.auto_resume == "on_activity"
+ assert lifecycle.delete_after == 86400
+
+ def test_disabled_lifecycle_timers_are_sent_as_unset(self):
+ backend, client = _backend_with_client(pause_after_idle=None,
delete_after=None)
+
+ backend.create()
+
+ lifecycle =
client.sandboxes.create_sandbox.call_args.kwargs["lifecycle"]
+ assert lifecycle.pause_after_idle is None
+ assert lifecycle.delete_after is None
+
+ def test_omitted_sizing_is_left_to_the_server(self):
+ backend, client = _backend_with_client()
+
+ backend.create()
+
+ assert not {"image", "vcpus", "memory_mb"} &
client.sandboxes.create_sandbox.call_args.kwargs.keys()
+
+ def test_api_failure_is_terminal(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.create_sandbox.side_effect = ApiError(status_code=503)
+
+ with pytest.raises(SandboxTerminalError, match="HTTP 503"):
+ backend.create()
+
+ def test_a_failed_create_deletes_the_name_it_had_already_bound(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.create_sandbox.side_effect = ApiError(status_code=503)
+
+ with pytest.raises(SandboxTerminalError):
+ backend.create()
+
+ # The server may have provisioned the microVM before failing to answer,
+ # and this name is the only handle that can still reclaim it.
+ requested = client.sandboxes.create_sandbox.call_args.kwargs["name"]
+ client.sandboxes.delete_sandbox.assert_called_once()
+ assert
client.sandboxes.delete_sandbox.call_args.kwargs["sandbox_name"] == requested
+
+ def test_a_cleanup_failure_does_not_mask_the_original_create_error(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.create_sandbox.side_effect = ApiError(status_code=503)
+ client.sandboxes.delete_sandbox.side_effect = ApiError(status_code=500)
+
+ with pytest.raises(SandboxTerminalError, match="HTTP 503"):
+ backend.create()
+
+ def
test_a_sandbox_that_cannot_serve_after_creation_is_destroyed_and_terminal(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.create_sandbox.return_value =
_sandbox_info(status="stopped")
+
+ with pytest.raises(SandboxTerminalError, match="cannot serve
requests"):
+ backend.create()
+
+ client.sandboxes.delete_sandbox.assert_called_once()
+
+ def test_an_empty_spec_env_sends_no_env(self):
+ backend, client = _backend_with_client()
+
+ backend.create(spec=SandboxSpec(env={}))
+
+ assert "env" not in client.sandboxes.create_sandbox.call_args.kwargs
+
+
+class TestRunCommand:
+ def test_polls_with_backoff(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.side_effect = [
+ _exec_result(status="running"),
+ _exec_result(status="running"),
+ _exec_result(status="running"),
+ _exec_result(stdout="done"),
+ ]
+
+ with mock.patch("time.sleep", autospec=True) as sleep:
+ result = backend.run_command("box", "x", timeout=60,
max_output_bytes=1024)
+
+ intervals = [call.args[0] for call in sleep.call_args_list]
+ assert result.stdout == "done"
+ assert intervals == sorted(intervals)
+ assert intervals[-1] > intervals[0]
+
+ def test_user_command_is_an_argument_to_the_bounding_wrapper(self):
+ backend, client = _backend_with_client()
+ user_command = "echo '$HOME'; rm -f /tmp/nope"
+
+ backend.run_command("box", user_command, timeout=5,
max_output_bytes=1024)
+
+ command = client.sandboxes.exec_in_sandbox.call_args.kwargs["command"]
+ assert command[:2] == ["sh", "-c"]
+ assert user_command not in command[2]
+ # One byte over the budget, so an over-budget stream proves truncation.
+ assert command[4:] == [user_command, "1025"]
+
+ def test_budget_above_the_server_cap_is_clamped_to_it(self):
+ backend, client = _backend_with_client()
+
+ backend.run_command("box", "x", timeout=5, max_output_bytes=5 *
_SERVER_STREAM_CAP)
+
+ command = client.sandboxes.exec_in_sandbox.call_args.kwargs["command"]
+ assert command[-1] == str(_SERVER_STREAM_CAP)
+
+ def test_polls_keep_the_sdk_retries_but_the_start_is_sent_once(self):
+ backend, client = _backend_with_client()
+
+ backend.run_command("box", "x", timeout=5, max_output_bytes=1024)
+
+ assert
client.sandboxes.exec_in_sandbox.call_args.kwargs["request_options"]["max_retries"]
== 0
+ assert "max_retries" not in
client.sandboxes.get_exec_result.call_args.kwargs["request_options"]
+
+ @staticmethod
+ def _real_client(monkeypatch, first_start):
+ """
+ A real SDK client whose first command start fails with ``first_start``.
+
+ The SDK's own retries are what the backend has to keep off this POST,
so
+ they must run for real rather than through a mocked
``SandboxesClient``.
+ """
+ # With a key the SDK exchanges it for a token over the network,
outside this
+ # transport, before the start is ever sent.
+ monkeypatch.delenv("ISLO_API_KEY", raising=False)
+ posts = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "GET":
+ return httpx.Response(
+ 200,
+ json={
+ "exec_id": "exec-2",
+ "status": "completed",
+ "exit_code": 0,
+ "stdout": "ok\n",
+ "stderr": "",
+ "truncated": False,
+ },
+ )
+ posts.append(request.url.path)
+ if len(posts) == 1:
+ if isinstance(first_start, Exception):
+ raise first_start
+ return first_start
+ return httpx.Response(200, json={"exec_id": "exec-2",
"sandbox_id": "sb-1", "status": "running"})
+
+ client = Islo(
+ base_url="https://islo.test",
+ compute_url="https://compute.islo.test",
+ httpx_client=httpx.Client(transport=httpx.MockTransport(handler)),
+ )
+ return client, posts
+
+ @pytest.mark.parametrize(
+ "first_start",
+ [httpx.RemoteProtocolError("connection dropped"), httpx.Response(503)],
+ ids=["lost-response", "5xx"],
+ )
+ @mock.patch(f"{_MODULE}.time.sleep", autospec=True)
+ def test_a_start_that_may_have_run_is_never_resent(self, sleep,
first_start, monkeypatch):
+ # Islo may have started the command before the response was lost or
the 5xx
+ # was sent, so a resend could run a "git push" or an ">> file" twice.
+ backend = IsloSandboxBackend()
+ backend._client, posts = self._real_client(monkeypatch, first_start)
+
+ with pytest.raises(SandboxTerminalError, match="start a sandbox
command"):
+ backend.run_command("box", "x", timeout=5, max_output_bytes=1024)
+
+ assert len(posts) == 1
+
+ @pytest.mark.parametrize(
+ "first_start",
+ [httpx.ConnectError("connection refused"), httpx.Response(429)],
+ ids=["no-connection", "rate-limited"],
+ )
+ @mock.patch(f"{_MODULE}.time.sleep", autospec=True)
+ def test_a_start_that_never_ran_is_resent(self, sleep, first_start,
monkeypatch):
+ backend = IsloSandboxBackend()
+ backend._client, posts = self._real_client(monkeypatch, first_start)
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ assert len(posts) == 2
+ assert result.stdout == "ok\n"
+ sleep.assert_called_once_with(1.0)
+
+ @pytest.mark.parametrize("status_code", [429, 503])
+ def test_a_start_whose_token_refresh_failed_transiently_is_resent(self,
status_code):
+ # The token refresh runs before the start is sent, so the command
never ran.
+ backend, client = _backend_with_client()
+ client.sandboxes.exec_in_sandbox.side_effect = [
+ _token_error(status_code),
+ SimpleNamespace(exec_id="exec-1"),
+ ]
+
+ with mock.patch(f"{_MODULE}.time.sleep", autospec=True):
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ assert client.sandboxes.exec_in_sandbox.call_count == 2
+ assert result.exit_code == 0
+
+ @pytest.mark.parametrize("status_code", [401, 403])
+ def test_a_start_with_a_rejected_key_is_not_resent(self, status_code):
+ backend, client = _backend_with_client()
+ client.sandboxes.exec_in_sandbox.side_effect =
_token_error(status_code)
+
+ with mock.patch(f"{_MODULE}.time.sleep", autospec=True):
+ with pytest.raises(SandboxTerminalError, match=rf"rejected
ISLO_API_KEY \(HTTP {status_code}\)"):
+ backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ assert client.sandboxes.exec_in_sandbox.call_count == 1
+
+ def test_a_start_that_keeps_failing_to_connect_gives_up(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.exec_in_sandbox.side_effect =
httpx.ConnectError("connection refused")
+
+ with mock.patch(f"{_MODULE}.time.sleep", autospec=True):
+ with pytest.raises(SandboxTerminalError, match="start a sandbox
command"):
+ backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ assert client.sandboxes.exec_in_sandbox.call_count == 3
+
+ def test_a_stream_within_budget_is_passed_through_untouched(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(stdout="a\nb\n", stderr="err\n")
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ assert result.stdout == "a\nb\n"
+ assert result.stderr == "err\n"
+ assert not result.stdout_truncated
+ assert not result.stderr_truncated
+
+ def test_an_over_budget_stream_keeps_the_tail_and_reports_truncation(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value = _exec_result(
+ stdout="line1\nline2\nline3\n", stderr="e1\ne2\ne3\n"
+ )
+
+ result = backend.run_command("box", "x", timeout=5, max_output_bytes=8)
+
+ assert result.stdout == "line3\n"
+ assert result.stderr == "e2\ne3\n"
+ assert result.stdout_truncated
+ assert result.stderr_truncated
+
+ def test_truncation_drops_a_short_partial_leading_line(self):
+ backend, client = _backend_with_client()
+ # A byte-aligned cut of the last 12 bytes would land inside "line988".
+ client.sandboxes.get_exec_result.return_value =
_exec_result(stdout="line988\nline989\n")
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=12)
+
+ assert result.stdout == "line989\n"
+ assert result.stdout_truncated
+
+ @pytest.mark.parametrize(
+ ("stdout", "expected"),
+ [
+ ("abcdefghij", "cdefghij"),
+ # The line's only newline is its last byte, so dropping through it
+ # would leave nothing and the model would read "(no output)".
+ ("x" * 20 + "\n", "xxxxxxx\n"),
+ ("z" * 20 + "\nok\n", "zzzz\nok\n"),
+ ],
+ ids=["no-newline", "trailing-newline", "short-line-after"],
+ )
+ def test_a_single_line_over_budget_is_cut_rather_than_dropped(self,
stdout, expected):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(stdout=stdout)
+
+ result = backend.run_command("box", "x", timeout=5, max_output_bytes=8)
+
+ assert result.stdout == expected
+ assert result.stdout_truncated
+
+ @pytest.mark.parametrize(
+ ("max_bytes", "expected"),
+ [(4, "éé"), (3, "\ufffdé")],
+ ids=["whole-characters", "cut-inside-a-character"],
+ )
+ def test_applies_the_byte_cap_to_utf8_and_replaces_a_split_character(self,
max_bytes, expected):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(stdout="ééé")
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=max_bytes)
+
+ assert result.stdout == expected
+ assert result.stdout_truncated
+
+ def test_the_server_flag_alone_does_not_mark_streams_that_fit(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value = _exec_result(
+ stdout="a\n", stderr="b\n", truncated=True
+ )
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ assert not result.stdout_truncated
+ assert not result.stderr_truncated
+
+ @mock.patch.object(IsloSandboxBackend, "_await_exec", autospec=True,
return_value=None)
+ def test_poll_deadline_destroys_the_sandbox(self, _await_exec):
+ backend, client = _backend_with_client()
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ assert result.timed_out
+ assert result.sandbox_terminated
+ client.sandboxes.delete_sandbox.assert_called_once()
+
+ @pytest.mark.parametrize(
+ ("delete_after", "reclaim"),
+ [
+ (3600, "its delete_after policy removes it 3600s after creation"),
+ (None, "delete_after is disabled, so it persists until deleted by
hand"),
+ ],
+ )
+ @mock.patch(f"{_MODULE}.log", autospec=True)
+ @mock.patch.object(IsloSandboxBackend, "_await_exec", autospec=True,
return_value=None)
+ def
test_timeout_cleanup_failure_warns_and_says_whether_anything_reclaims_it(
+ self, _await_exec, logger, delete_after, reclaim
+ ):
+ backend, client = _backend_with_client(delete_after=delete_after)
+ client.sandboxes.delete_sandbox.side_effect = ApiError(status_code=503)
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ # A command that merely ran long must not fail the task because one
+ # cleanup call was refused, but the operator must learn whether the
+ # microVM will be reclaimed at all.
+ assert result.timed_out
+ assert result.sandbox_terminated
+ assert "could not confirm its deletion" in
logger.warning.call_args.args[0]
+ assert logger.warning.call_args.args[2] == reclaim
+
+ @pytest.mark.parametrize("status", ["cancelled", "dead", "something-new"])
+ def
test_an_unrecognised_status_is_terminal_rather_than_still_running(self, status):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(status=status, exit_code=None)
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ # The vendor types status as a plain string, so the vocabulary can
grow.
+ # Reading an unknown value as still-running would poll to the deadline
+ # and then destroy the sandbox, costing the agent its files.
+ assert result.exit_code == -1
+ assert not result.timed_out
+ assert not result.sandbox_terminated
+ client.sandboxes.delete_sandbox.assert_not_called()
+
+ @pytest.mark.parametrize("status", ["pending", "queued", "starting",
"running"])
+ def test_in_flight_statuses_keep_polling(self, status):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.side_effect = [
+ _exec_result(status=status),
+ _exec_result(stdout="eventually\n"),
+ ]
+
+ with mock.patch("time.sleep", autospec=True):
+ result = backend.run_command("box", "x", timeout=60,
max_output_bytes=1024)
+
+ assert result.stdout == "eventually\n"
+
+ def test_server_timeout_also_destroys_the_sandbox(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(status="timeout", exit_code=None)
+
+ result = backend.run_command("box", "x", timeout=5,
max_output_bytes=1024)
+
+ assert result.timed_out
+ assert result.exit_code == -1
+ assert result.sandbox_terminated
+ client.sandboxes.delete_sandbox.assert_called_once()
+
+ def test_start_failure_is_terminal(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.exec_in_sandbox.side_effect =
ApiError(status_code=401)
+
+ with pytest.raises(SandboxTerminalError, match="HTTP 401"):
+ backend.run_command("box", "x", timeout=5, max_output_bytes=1024)
+
+ def test_transient_poll_errors_are_ridden_out(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.side_effect = [
+ ApiError(status_code=502),
+ httpx.ReadError("connection reset"),
+ ApiError(status_code=None),
+ _token_error(503),
+ _exec_result(stdout="done\n"),
+ ]
+
+ with mock.patch("time.sleep", autospec=True):
+ result = backend.run_command("box", "x", timeout=60,
max_output_bytes=1024)
+
+ assert result.stdout == "done\n"
+ assert not result.timed_out
+ client.sandboxes.delete_sandbox.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("error", "message"),
+ [
+ (ApiError(status_code=404), "poll a sandbox command"),
+ (RuntimeError("bad client state"), "poll a sandbox command"),
+ (_token_error(401), r"rejected ISLO_API_KEY \(HTTP 401\)"),
+ (_token_error(403), r"rejected ISLO_API_KEY \(HTTP 403\)"),
+ (_token_error(400), r"poll a sandbox command \(HTTP 400\)"),
+ ],
+ ids=["404", "client-error", "rejected-key", "forbidden-key",
"token-4xx"],
+ )
+ def test_a_non_transient_poll_error_is_terminal_at_once(self, error,
message):
+ backend, client = _backend_with_client()
+ # A misclassified error would be ridden out to this result, so the
test fails
+ # fast instead of spinning to the deadline.
+ client.sandboxes.get_exec_result.side_effect = [error, _exec_result()]
+
+ with mock.patch("time.sleep", autospec=True):
+ with pytest.raises(SandboxTerminalError, match=message):
+ backend.run_command("box", "x", timeout=60,
max_output_bytes=1024)
+
+ # Raised on the first poll, not ridden out to the deadline.
+ assert client.sandboxes.get_exec_result.call_count == 1
+
+ def
test_transient_errors_lasting_past_the_deadline_are_terminal_not_a_timeout(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.side_effect =
ApiError(status_code=503)
+
+ with pytest.raises(SandboxTerminalError, match="poll a sandbox
command"):
+ backend.run_command("box", "x", timeout=0.05,
max_output_bytes=1024)
+
+ # Nothing was heard from the command, so claiming it timed out would
be a guess.
+ client.sandboxes.delete_sandbox.assert_not_called()
+
+ @pytest.mark.parametrize("request_name", ["exec_in_sandbox",
"get_exec_result"])
+ def test_the_start_and_the_last_poll_keep_a_minimum_http_budget(self,
request_name):
+ backend, client = _backend_with_client()
+
+ backend.run_command("box", "x", timeout=1, max_output_bytes=1024)
+
+ options = getattr(client.sandboxes,
request_name).call_args.kwargs["request_options"]
+ assert options["timeout_in_seconds"] == 5
+
+ def test_a_slow_start_leaves_the_command_its_whole_budget(self,
monkeypatch):
+ backend, client = _backend_with_client()
+ clock = [1000.0]
+ monkeypatch.setattr(f"{_MODULE}.time.monotonic", lambda: clock[0])
+
+ def slow_start(*args, **kwargs):
+ # Longer than the command's whole budget, as the first call after
an
+ # auto-resume can be.
+ clock[0] += 10
+ return SimpleNamespace(exec_id="exec-1")
+
+ client.sandboxes.exec_in_sandbox.side_effect = slow_start
+ client.sandboxes.get_exec_result.return_value =
_exec_result(stdout="hi\n")
+
+ result = backend.run_command("box", "echo hi", timeout=1,
max_output_bytes=1024)
+
+ assert result.stdout == "hi\n"
+ assert not result.timed_out
+ client.sandboxes.delete_sandbox.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("timeout", "max_bytes", "message"), [(0, 1, "timeout"), (1, 0,
"max_output_bytes")]
+ )
+ def test_rejects_invalid_budgets(self, timeout, max_bytes, message):
+ backend, _ = _backend_with_client()
+
+ with pytest.raises(ValueError, match=message):
+ backend.run_command("box", "x", timeout=timeout,
max_output_bytes=max_bytes)
+
+
+class TestFileOperations:
+ def test_read_file_uses_the_native_streaming_api(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.download_file.return_value = iter([b"he", b"llo"])
+
+ data = backend.read_file("box", "/w/a", max_bytes=100)
+
+ assert data == b"hello"
+ client.sandboxes.download_file.assert_called_once_with(
+ "box",
+ path="/w/a",
+ request_options={"timeout_in_seconds": 120, "chunk_size": 101},
+ )
+ client.sandboxes.exec_in_sandbox.assert_not_called()
+
+ def
test_oversized_read_stops_closes_the_stream_and_reports_the_real_size(self):
+ backend, client = _backend_with_client()
+ closed: list[bool] = []
+
+ def chunks():
+ try:
+ yield b"x" * 11
+ raise AssertionError("the backend must stop after the sentinel
byte")
+ finally:
+ closed.append(True)
+
+ client.sandboxes.download_file.return_value = chunks()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(stdout="123456\n")
+
+ with pytest.raises(SandboxFileTooLargeError) as error:
+ backend.read_file("box", "/w/a", max_bytes=10)
+
+ assert closed == [True]
+ assert error.value.size_bytes == 123456
+ assert
client.sandboxes.exec_in_sandbox.call_args.kwargs["command"][4].startswith("stat
-Lc %s -- ")
+
+ def
test_oversized_read_without_a_size_says_so_rather_than_inventing_one(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.download_file.return_value = iter([b"x" * 11])
+ client.sandboxes.get_exec_result.return_value =
_exec_result(exit_code=1, stderr="stat: no such file")
+
+ with pytest.raises(SandboxError, match="larger than the 10 byte read
limit") as error:
+ backend.read_file("box", "/w/a", max_bytes=10)
+
+ assert not isinstance(error.value, (SandboxTerminalError,
SandboxFileTooLargeError))
+
+ def test_missing_file_is_recoverable_when_the_sandbox_exists(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.download_file.side_effect = NotFoundError({})
+
+ with pytest.raises(SandboxError, match="does not exist") as error:
+ backend.read_file("box", "/w/missing", max_bytes=100)
+
+ assert not isinstance(error.value, SandboxTerminalError)
+ client.sandboxes.get_sandbox.assert_called_once()
+
+ @pytest.mark.parametrize(
+ "info",
+ [_sandbox_info(status="stopped"),
_sandbox_info(deleted_at="2026-09-19T00:00:00Z")],
+ ids=["stopped", "deleted"],
+ )
+ def test_missing_file_on_a_sandbox_that_cannot_serve_is_terminal(self,
info):
+ backend, client = _backend_with_client()
+ client.sandboxes.download_file.side_effect = NotFoundError({})
+ client.sandboxes.get_sandbox.return_value = info
+
+ with pytest.raises(SandboxTerminalError, match="cannot serve
requests"):
+ backend.read_file("box", "/w/a", max_bytes=100)
+
+ @pytest.mark.parametrize(("auto_resume", "terminal"), [("on_activity",
False), ("never", True)])
+ def test_a_paused_sandbox_is_usable_only_when_it_resumes_on_activity(self,
auto_resume, terminal):
+ backend, client = _backend_with_client(auto_resume=auto_resume)
+ client.sandboxes.download_file.side_effect = NotFoundError({})
+ client.sandboxes.get_sandbox.return_value =
_sandbox_info(status="paused")
+
+ with pytest.raises(SandboxError) as error:
+ backend.read_file("box", "/w/a", max_bytes=100)
+
+ assert isinstance(error.value, SandboxTerminalError) is terminal
+
+ def test_missing_sandbox_is_terminal(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.download_file.side_effect = NotFoundError({})
+ client.sandboxes.get_sandbox.side_effect = NotFoundError({})
+
+ with pytest.raises(SandboxTerminalError, match="check a sandbox"):
+ backend.read_file("box", "/w/a", max_bytes=100)
+
+ @pytest.mark.parametrize(
+ "error",
+ [
+ ApiError(status_code=503),
+ ApiError(status_code=401),
+ ApiError(status_code=429),
+ httpx.ConnectError("reset"),
+ ],
+ ids=["503", "401", "429", "no-response"],
+ )
+ def test_a_failure_that_says_nothing_about_the_path_is_terminal(self,
error):
+ backend, client = _backend_with_client()
+ client.sandboxes.download_file.side_effect = error
+
+ with pytest.raises(SandboxTerminalError, match="read a sandbox file"):
+ backend.read_file("box", "/w/a", max_bytes=100)
+
+ client.sandboxes.get_sandbox.assert_not_called()
+
+ def test_a_rejected_path_is_recoverable_and_carries_the_api_message(self):
+ # Measured: a relative path is a 400 whose message names the problem.
+ backend, client = _backend_with_client()
+ client.sandboxes.download_file.side_effect = ApiError(
+ status_code=400,
+ body={"code": "INVALID_REQUEST", "message": "path must be
absolute: rel.txt"},
+ )
+
+ with pytest.raises(SandboxError) as error:
+ backend.read_file("box", "rel.txt", max_bytes=100)
+
+ assert not isinstance(error.value, SandboxTerminalError)
+ assert str(error.value) == "Could not read 'rel.txt' (HTTP 400): path
must be absolute: rel.txt."
+
+ def test_a_long_api_message_is_trimmed(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.download_file.side_effect = ApiError(status_code=400,
body={"message": "x" * 5000})
+
+ with pytest.raises(SandboxError) as error:
+ backend.read_file("box", "/w/a", max_bytes=100)
+
+ assert len(str(error.value)) < 300
+
+ def test_write_file_creates_parents_then_uses_native_upload(self):
+ backend, client = _backend_with_client()
+
+ backend.write_file("box", "/w/sub/a", b"data")
+
+ command = client.sandboxes.exec_in_sandbox.call_args.kwargs["command"]
+ assert "mkdir -p" in command[4]
+ client.sandboxes.upload_file.assert_called_once_with(
+ "box",
+ path="/w/sub/a",
+ file=("upload", b"data", "application/octet-stream"),
+ request_options={"timeout_in_seconds": 120},
+ )
+
+ def test_write_stops_when_parent_creation_fails(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(exit_code=1, stderr="read-only")
+
+ with pytest.raises(SandboxError, match="read-only"):
+ backend.write_file("box", "/w/a", b"data")
+
+ client.sandboxes.upload_file.assert_not_called()
+
+ def test_upload_outage_is_terminal(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.upload_file.side_effect = ApiError(status_code=503)
+
+ with pytest.raises(SandboxTerminalError, match="write a sandbox file"):
+ backend.write_file("box", "/w/a", b"data")
+
+ def test_an_unwritable_target_is_recoverable_with_a_hint(self):
+ # Measured: uploading onto a directory, or into /proc or /sys, is a
bare
+ # 500 whose body says only "An internal error occurred".
+ backend, client = _backend_with_client()
+ client.sandboxes.upload_file.side_effect = ApiError(
+ status_code=500, body={"code": "INTERNAL_ERROR", "message": "An
internal error occurred"}
+ )
+
+ with pytest.raises(SandboxError) as error:
+ backend.write_file("box", "/tmp", b"data")
+
+ assert not isinstance(error.value, SandboxTerminalError)
+ assert "(HTTP 500)." in str(error.value)
+ assert "directory or under a pseudo-filesystem such as /proc or /sys"
in str(error.value)
+ assert "internal error" not in str(error.value)
+ client.sandboxes.get_sandbox.assert_called_once()
+
+ def test_an_upload_error_on_a_sandbox_that_cannot_serve_is_terminal(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.upload_file.side_effect = ApiError(status_code=500)
+ client.sandboxes.get_sandbox.return_value =
_sandbox_info(status="stopped")
+
+ with pytest.raises(SandboxTerminalError, match="cannot serve
requests"):
+ backend.write_file("box", "/w/a", b"data")
+
+ def test_list_directory_marks_directories_and_preserves_newlines(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value = _exec_result(stdout="f
a.txt\0d new\nline\0")
+
+ assert backend.list_directory("box", "/w") == [("a.txt", False),
("new\nline", True)]
+
+ @mock.patch(f"{_MODULE}._HELPER_OUTPUT_CAP", 20)
+ def test_a_listing_cut_at_the_head_drops_only_the_leading_record(self):
+ backend, client = _backend_with_client()
+ # The 20-byte tail starts at " name\0", a fragment that would parse as
an
+ # entry called "name" if the leading record were kept.
+ client.sandboxes.get_exec_result.return_value = _exec_result(
+ stdout="f old file name\0d bbbb\0d cccc\0"
+ )
+
+ assert backend.list_directory("box", "/w") == [("bbbb", True),
("cccc", True)]
+
+ def test_the_server_flag_alone_leaves_a_short_listing_intact(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value = _exec_result(
+ stdout="f a.txt\0d sub\0", truncated=True
+ )
+
+ assert backend.list_directory("box", "/w") == [("a.txt", False),
("sub", True)]
+
+ def test_list_failure_is_recoverable(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(exit_code=1, stderr="missing")
+
+ with pytest.raises(SandboxError, match="missing"):
+ backend.list_directory("box", "/w")
+
+ def test_helper_timeout_is_terminal(self):
+ backend, _ = _backend_with_client()
+ backend.run_command = mock.create_autospec(
+ backend.run_command,
+ return_value=SandboxExecResult(
+ exit_code=-1,
+ stdout="",
+ stderr="",
+ timed_out=True,
+ sandbox_terminated=True,
+ ),
+ )
+
+ with pytest.raises(SandboxTerminalError, match="destroyed"):
+ backend.list_directory("box", "/w")
+
+ def test_read_rejects_an_invalid_budget(self):
+ backend, _ = _backend_with_client()
+
+ with pytest.raises(ValueError, match="max_bytes"):
+ backend.read_file("box", "/w/a", max_bytes=0)
+
+
+class TestExportFile:
+ def test_streams_a_file_larger_than_a_command_can_return(self):
+ backend, client = _backend_with_client()
+ chunks = [bytes([n]) * (512 * 1024) for n in range(3)]
+ data = b"".join(chunks)
+ client.sandboxes.get_exec_result.return_value =
_export_check(len(data))
+ client.sandboxes.download_file.return_value = iter(chunks)
+ dest = io.BytesIO()
+
+ written = backend.export_file("box", "/w/out.bin", dest, max_bytes=10
* 1024 * 1024)
+
+ # Over the 1 MiB the server returns per stream, which the default's
+ # base64 slices through run_command could never carry.
+ assert len(data) > _SERVER_STREAM_CAP
+ assert written == len(data)
+ assert dest.getvalue() == data
+ client.sandboxes.download_file.assert_called_once_with(
+ "box",
+ path="/w/out.bin",
+ request_options={"timeout_in_seconds": 120, "chunk_size": 65536},
+ )
+ client.sandboxes.exec_in_sandbox.assert_called_once()
+
+ @pytest.mark.parametrize(
+ ("relative", "quoted"),
+ [("report.csv", "report.csv"), ("report.csv ", "'report.csv '")],
+ ids=["plain", "trailing-space"],
+ )
+ def
test_a_relative_path_is_resolved_in_the_guest_before_it_is_downloaded(self,
relative, quoted):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.side_effect = [
+ _export_check(4),
+ _exec_result(stdout=f"/workspace/{relative}\n"),
+ ]
+ client.sandboxes.download_file.return_value = iter([b"abcd"])
+ dest = io.BytesIO()
+
+ written = backend.export_file("box", relative, dest, max_bytes=100)
+
+ # The download API answers a relative path with a 400, so only the
resolved
+ # absolute path may reach it, and whitespace that is part of the name
stays.
+ assert written == 4
+ assert dest.getvalue() == b"abcd"
+ resolve =
client.sandboxes.exec_in_sandbox.call_args_list[1].kwargs["command"][4]
+ assert resolve == f"realpath -e -- {quoted}"
+ assert client.sandboxes.download_file.call_args.kwargs["path"] ==
f"/workspace/{relative}"
+
+ @pytest.mark.parametrize(
+ ("exit_code", "stderr", "error", "message"),
+ [
+ (IsloSandboxBackend._MISSING_PATH_STATUS, "", SandboxError, "does
not exist"),
+ (IsloSandboxBackend._IS_DIRECTORY_STATUS, "", SandboxError, "is a
directory"),
+ (IsloSandboxBackend._NOT_REGULAR_FILE_STATUS, "", SandboxError,
"is not a regular file"),
+ (
+ IsloSandboxBackend._TOO_LARGE_STATUS,
+ "\nairflow-export-size:5000\n",
+ SandboxFileTooLargeError,
+ "is 5000 bytes",
+ ),
+ ],
+ ids=["missing", "directory", "not-regular", "too-large"],
+ )
+ def test_the_guest_check_refuses_before_anything_is_downloaded(self,
exit_code, stderr, error, message):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value =
_exec_result(exit_code=exit_code, stderr=stderr)
+
+ with pytest.raises(error, match=message) as raised:
+ backend.export_file("box", "/w/a", io.BytesIO(), max_bytes=100)
+
+ assert not isinstance(raised.value, SandboxTerminalError)
+ client.sandboxes.download_file.assert_not_called()
+
+ def test_a_stream_longer_than_the_budget_is_stopped_and_closed(self):
+ backend, client = _backend_with_client()
+ closed: list[bool] = []
+
+ def chunks():
+ try:
+ yield b"x" * 6
+ yield b"x" * 6
+ raise AssertionError("the backend must stop once the budget is
passed")
+ finally:
+ closed.append(True)
+
+ # Within budget when the guest checked it, then the file grew.
+ client.sandboxes.get_exec_result.return_value = _export_check(10)
+ client.sandboxes.download_file.return_value = chunks()
+ dest = io.BytesIO()
+
+ with pytest.raises(SandboxFileTooLargeError) as error:
+ backend.export_file("box", "/w/a", dest, max_bytes=10)
+
+ assert error.value.size_bytes == 12
+ assert dest.getvalue() == b"x" * 6
+ assert closed == [True]
+
+ def test_a_file_that_changed_size_while_exported_is_an_error(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value = _export_check(10)
+ client.sandboxes.download_file.return_value = iter([b"abcd"])
+
+ with pytest.raises(SandboxError, match="changed while it was
exported"):
+ backend.export_file("box", "/w/a", io.BytesIO(), max_bytes=100)
+
+ @pytest.mark.parametrize(
+ ("error", "expected", "message"),
+ [
+ (NotFoundError({}), SandboxError, "does not exist"),
+ (ApiError(status_code=503), SandboxTerminalError, "export a
sandbox file"),
+ ],
+ ids=["404", "503"],
+ )
+ def test_a_failed_download_is_classified_like_any_file_transfer(self,
error, expected, message):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value = _export_check(10)
+ client.sandboxes.download_file.side_effect = error
+
+ with pytest.raises(SandboxError, match=message) as raised:
+ backend.export_file("box", "/w/a", io.BytesIO(), max_bytes=100)
+
+ assert type(raised.value) is expected
+
+ def test_a_failing_destination_is_not_reported_as_a_sandbox_error(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value = _export_check(4)
+ client.sandboxes.download_file.return_value = iter([b"abcd"])
+ dest = mock.create_autospec(io.BytesIO, instance=True)
+ dest.write.side_effect = OSError("object store unavailable")
+
+ with pytest.raises(OSError, match="object store unavailable"):
+ backend.export_file("box", "/w/a", dest, max_bytes=100)
+
+ client.sandboxes.get_sandbox.assert_not_called()
+
+ @mock.patch(f"{_MODULE}._export_deadline", autospec=True, return_value=0.0)
+ def test_an_export_past_its_deadline_is_ended(self, _export_deadline):
+ backend, client = _backend_with_client()
+ client.sandboxes.get_exec_result.return_value = _export_check(4)
+ client.sandboxes.download_file.return_value = iter([b"abcd"])
+ dest = io.BytesIO()
+
+ with pytest.raises(SandboxError, match="took longer than"):
+ backend.export_file("box", "/w/a", dest, max_bytes=100)
+
+ assert dest.getvalue() == b""
+
+ def test_rejects_an_invalid_budget(self):
+ backend, client = _backend_with_client()
+
+ with pytest.raises(ValueError, match="max_bytes"):
+ backend.export_file("box", "/w/a", io.BytesIO(), max_bytes=0)
+
+ client.sandboxes.exec_in_sandbox.assert_not_called()
+
+
+class TestDestroy:
+ def test_is_idempotent_when_the_sandbox_is_already_gone(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.delete_sandbox.side_effect = NotFoundError({})
+
+ backend.destroy("box")
+
+ def test_delete_failure_is_terminal(self):
+ backend, client = _backend_with_client()
+ client.sandboxes.delete_sandbox.side_effect = ApiError(status_code=503)
+
+ with pytest.raises(SandboxTerminalError, match="delete a sandbox"):
+ backend.destroy("box")
+
+ def test_delete_keeps_the_sdk_retries(self):
+ backend, client = _backend_with_client()
+
+ backend.destroy("box")
+
+ # Deletion is idempotent and is the only call whose failure strands a
+ # microVM, so it must not be the one call that never retries.
+ options =
client.sandboxes.delete_sandbox.call_args.kwargs["request_options"]
+ assert options["max_retries"] > 0
+
+
+def _run_wrapper(command: str, max_output_bytes: int, *, timeout: float =
30.0, env: dict | None = None):
+ """Run the real wrapper through a local ``sh``, exactly as the backend
invokes it."""
+ return subprocess.run(
+ [
+ "sh",
+ "-c",
+ _COMMAND_WRAPPER,
+ "airflow-sandbox",
+ command,
+ str(max_output_bytes + 1),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ env=env,
+ )
+
+
+class TestCommandWrapper:
+ """
+ Execute ``_COMMAND_WRAPPER`` for real, rather than asserting on its text.
+
+ Every other test in this module mocks ``exec_in_sandbox``, so without these
+ the wrapper only ever runs in the system test, which needs a live API key
and
+ so does not run in ordinary CI. A local ``sh`` needs no Islo access at all.
+ """
+
+ def test_reports_stdout_stderr_and_the_exit_status_separately(self):
+ result = _run_wrapper("echo out; echo err >&2; exit 3", 1024)
+
+ assert result.stdout == "out\n"
+ assert result.stderr == "err\n"
+ assert result.returncode == 3
+
+ def test_the_process_environment_reaches_the_command_unchanged(self):
+ # A login shell would run /etc/profile after the spec's variables were
set,
+ # and Debian's and macOS's both rewrite PATH.
+ env = {**os.environ, "PATH": "/spec/wins:" + os.environ["PATH"]}
+ result = _run_wrapper('echo "${PATH%%:*}"', 1024, env=env)
+
+ assert result.stdout == "/spec/wins\n"
+
+ def test_a_backgrounded_process_does_not_hold_the_command_open(self):
+ # The command's foreground part finishes at once. Waiting for the
capture
+ # to reach end-of-input would block until the backgrounded child exits,
+ # and past the deadline the backend destroys the sandbox -- so the
agent
+ # would lose its files over a command that already finished.
+ start = time.monotonic()
+ result = _run_wrapper("sleep 20 & echo $! >&2; echo started", 1024)
+ elapsed = time.monotonic() - start
+ try:
+ assert result.stdout == "started\n"
+ assert result.returncode == 0
+ assert elapsed < 5.0
+ finally:
+ with suppress(ProcessLookupError):
+ os.kill(int(result.stderr.strip()), signal.SIGTERM)
+
+ def test_a_long_lived_daemon_does_not_hold_the_command_open(self):
+ start = time.monotonic()
+ result = _run_wrapper("nohup sleep 300 >/dev/null 2>&1 & echo $! >&2;
echo server-started", 1024)
+ elapsed = time.monotonic() - start
+ try:
+ assert result.stdout == "server-started\n"
+ assert elapsed < 5.0
+ finally:
+ with suppress(ProcessLookupError):
+ os.kill(int(result.stderr.strip()), signal.SIGTERM)
+
+ def test_does_not_change_the_permissions_of_what_the_agent_creates(self,
tmp_path):
+ script = "touch a_file && mkdir a_dir && stat -c '%a %n' a_dir a_file
2>/dev/null || stat -f '%Lp %N' a_dir a_file"
+ (tmp_path / "wrapped").mkdir()
+ (tmp_path / "bare").mkdir()
+
+ wrapped = _run_wrapper(f"cd {tmp_path / 'wrapped'} && {script}", 4096)
+ bare = subprocess.run(
+ ["sh", "-c", f"cd {tmp_path / 'bare'} && {script}"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ # A umask left in force for the agent's command would tighten these;
the
+ # baseline is the host's own umask, so the test holds whatever that is.
+ assert wrapped.returncode == 0
+ assert wrapped.stdout == bare.stdout
+
+ def
test_the_scratch_directory_is_private_while_in_use_and_gone_after(self,
tmp_path):
+ scratch_root = tmp_path
+ pattern = "airflow-sandbox-[0-9]*"
+
+ process = subprocess.Popen(
+ ["sh", "-c", _COMMAND_WRAPPER, "airflow-sandbox", "sleep 2",
"1024"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env={**os.environ, "TMPDIR": str(tmp_path)},
+ )
+ try:
+ deadline = time.monotonic() + 5.0
+ scratch: list[Path] = []
+ while not scratch and time.monotonic() < deadline:
+ scratch = list(scratch_root.glob(pattern))
+ time.sleep(0.05)
+ assert scratch, "the wrapper never created its scratch directory"
+ # The capture files sit here, so the agent's command must not be
able
+ # to hand them to another user in a shared image.
+ assert scratch[0].stat().st_mode & 0o777 == 0o700
+ finally:
+ process.communicate(timeout=30)
+
+ assert not list(scratch_root.glob(pattern))
+
+ def test_keeps_the_tail_and_drops_a_short_partial_leading_line(self):
+ cap = 100
+ result = _run_wrapper("i=1; while [ $i -le 1000 ]; do echo line$i;
i=$((i+1)); done", cap)
+
+ # The wrapper is asked for cap+1 bytes, so the backend can tell the
+ # stream was over budget. These records are a few bytes each, so the
+ # cut one is dropped and every record left is whole.
+ assert len(result.stdout.encode()) == cap + 1
+ payload, truncated = _bound_result_stream(result.stdout, cap)
+ assert truncated
+ assert payload.endswith("line1000\n")
+ assert all(line.startswith("line") for line in payload.splitlines())
+
+ def test_a_terminated_command_exits_143_and_emits_nothing(self):
+ process = subprocess.Popen(
+ ["sh", "-c", _COMMAND_WRAPPER, "airflow-sandbox", "sleep 30",
"1024"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ start_new_session=True,
+ )
+ pgid = os.getpgid(process.pid)
+ # The traps are in place once the agent's command itself is running,
+ # which on a loaded host can take longer than any fixed pause.
+ deadline = time.monotonic() + 10.0
+ while (
+ subprocess.run(
+ ["pgrep", "-g", str(pgid), "-x", "sleep"],
capture_output=True, check=False
+ ).returncode
+ and time.monotonic() < deadline
+ ):
+ time.sleep(0.05)
+ # What stopping the microVM looks like from inside it.
+ os.killpg(pgid, signal.SIGTERM)
+ stdout, stderr = process.communicate(timeout=30)
+
+ # Without the trap the wrapper dies of the signal itself (a negative
+ # return code); a trap that cleans up and then falls through reaches
+ # ``tail`` with its scratch files already deleted and reports that on
+ # stderr. bash may still announce the child's death ("Terminated: 15"),
+ # which is the shell's notice, not wrapper output.
+ assert process.returncode == 143
+ assert stdout == ""
+ assert "No such file" not in stderr
diff --git a/uv.lock b/uv.lock
index 5adc4dda962..3a470996ebd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4595,6 +4595,9 @@ git = [
google = [
{ name = "pydantic-ai-slim", extra = ["google"] },
]
+islo = [
+ { name = "islo" },
+]
langchain = [
{ name = "langchain" },
]
@@ -4651,6 +4654,7 @@ dev = [
{ name = "apache-airflow-providers-standard" },
{ name = "apache-airflow-task-sdk" },
{ name = "boat-sdk" },
+ { name = "islo" },
{ name = "langchain" },
{ name = "llama-index-core" },
{ name = "llama-index-embeddings-openai" },
@@ -4680,6 +4684,7 @@ requires-dist = [
{ name = "dataclasses-json", marker = "extra == 'llamaindex'", specifier =
">=0.6.7" },
{ name = "fastavro", marker = "python_full_version >= '3.14' and extra ==
'avro'", specifier = ">=1.12.1" },
{ name = "fastavro", marker = "python_full_version < '3.14' and extra ==
'avro'", specifier = ">=1.10.0" },
+ { name = "islo", marker = "extra == 'islo'", specifier = ">=0.3.19" },
{ name = "langchain", marker = "extra == 'langchain'", specifier =
">=1.0.0" },
{ name = "llama-index-core", marker = "extra == 'llamaindex'", specifier =
">=0.14.5" },
{ name = "llama-index-embeddings-openai", marker = "extra ==
'llamaindex'", specifier = ">=0.6.0" },
@@ -4704,7 +4709,7 @@ requires-dist = [
{ name = "structlog", specifier = ">=24.2.0" },
{ name = "typesafe-sdk", marker = "extra == 'typesafe'", specifier =
">=0.6.0" },
]
-provides-extras = ["anthropic", "bedrock", "google", "openai", "typesafe",
"mcp", "modal", "opensandbox", "boat", "code-mode", "shields", "skills",
"avro", "parquet", "sql", "common-sql", "langchain", "llamaindex", "pdf",
"docx", "git"]
+provides-extras = ["anthropic", "bedrock", "google", "openai", "typesafe",
"mcp", "modal", "opensandbox", "boat", "code-mode", "shields", "skills",
"islo", "avro", "parquet", "sql", "common-sql", "langchain", "llamaindex",
"pdf", "docx", "git"]
[package.metadata.requires-dev]
dev = [
@@ -4719,6 +4724,7 @@ dev = [
{ name = "apache-airflow-providers-standard", editable =
"providers/standard" },
{ name = "apache-airflow-task-sdk", editable = "task-sdk" },
{ name = "boat-sdk", specifier = ">=1.9.3" },
+ { name = "islo", specifier = ">=0.3.19" },
{ name = "langchain", specifier = ">=1.0.0" },
{ name = "llama-index-core", specifier = ">=0.14.5" },
{ name = "llama-index-embeddings-openai", specifier = ">=0.6.0" },
@@ -15314,6 +15320,20 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl",
hash =
"sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size
= 8074, upload-time = "2025-01-17T11:24:33.271Z" },
]
+[[package]]
+name = "islo"
+version = "0.3.19"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/45/87/7678bf4559e775c58e80df263faff46b9f1cef626540bc852dbb8157ff8f/islo-0.3.19.tar.gz",
hash =
"sha256:b3f9fa7b996551eb6e77b6c0202f3af0c1d21935eefd4933b0232b271a464612", size
= 155588, upload-time = "2026-08-27T12:48:20.149Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/c0/b5/a8ecdea027ccc20f5ab44042c8629cd22588ca7c33cc199c5f103f92ead4/islo-0.3.19-py3-none-any.whl",
hash =
"sha256:ab7e9bab5678e486e0bdfa8ed25e40fe2eef95c5748c3d666b5c6b1226354126", size
= 372082, upload-time = "2026-08-27T12:48:18.582Z" },
+]
+
[[package]]
name = "isodate"
version = "0.7.2"