zozo123 commented on code in PR #71725:
URL: https://github.com/apache/airflow/pull/71725#discussion_r4130164914
##########
providers/common/ai/pyproject.toml:
##########
@@ -118,6 +118,7 @@ dependencies = [
"apache-airflow-providers-git>=0.4.0",
"pydantic-ai-skills>=1.2.0",
]
+"sandbox-boat" = ["boat-sdk>=1.0.0"]
Review Comment:
Validated on the current branch: the optional extra, SDK dependency,
missing-SDK message, tests, README, and docs consistently use boat / boat-sdk.
This outdated thread remains resolved.
##########
providers/common/ai/src/airflow/providers/common/ai/sandbox/boat.py:
##########
@@ -0,0 +1,492 @@
+# 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.
+"""Boat backend for
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`."""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import math
+import shlex
+from contextlib import contextmanager, suppress
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.common.ai.sandbox.base import (
+ SandboxBackend,
+ SandboxError,
+ SandboxExecResult,
+ SandboxTerminalError,
+ _new_sandbox_name,
+ _validate_positive_finite,
+)
+from airflow.providers.common.compat.sdk import BaseHook
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from boat_sdk import ApiClient
+ from boat_sdk.api.boat_api import BoatApi
+
+ from airflow.providers.common.ai.sandbox.base import SandboxSpec
+
+log = logging.getLogger(__name__)
+
+_DEFAULT_BASE_URL = "https://boat.dev/api/v1"
+_MAX_COMMAND_TIMEOUT = 600
+_MAX_ERROR_DETAIL = 300
+_FILE_OP_TIMEOUT = 120.0
+_HELPER_OUTPUT_CAP = 1024 * 1024
+_READY_STATES = frozenset({"ready", "idle", "running"})
+_MACHINE_TYPES = frozenset({"small", "default", "large"})
+
+
+def _api_error_detail(body: Any) -> str:
+ """
+ Summarize the ``code``/``message`` pair Boat returns in a failed call's
body.
+
+ The status code alone hides reasons the task owner has to act on, such as
an
+ account that has no Boat subscription yet. Returns an empty string when the
+ body is missing or is not the documented JSON error envelope.
+ """
+ if isinstance(body, bytes):
+ with suppress(UnicodeDecodeError):
+ body = body.decode("utf-8")
+ if not isinstance(body, str):
+ return ""
+ try:
+ payload = json.loads(body)
+ except ValueError:
+ return ""
+ if not isinstance(payload, dict):
+ return ""
+ error = payload.get("error")
+ if not isinstance(error, dict):
+ error = payload
+ parts = [
+ value.strip()
+ for key in ("code", "message")
+ if isinstance(value := error.get(key), str) and value.strip()
+ ]
+ if len(parts) == 2 and parts[0] == parts[1]:
+ del parts[1]
+ detail = ": ".join(parts)
+ return detail[:_MAX_ERROR_DETAIL]
+
+
+@contextmanager
+def _translate_boat_errors(
+ operation: str, *, recoverable_statuses: frozenset[int] = frozenset()
+) -> Iterator[None]:
+ try:
+ yield
+ except SandboxError:
+ raise
+ except Exception as e:
+ try:
+ from boat_sdk.exceptions import ApiException
+ except ImportError:
+ raise SandboxTerminalError(
+ 'The Boat SDK is not installed. Install
"apache-airflow-providers-common-ai[sandbox-boat]".'
+ ) from e
+ if isinstance(e, ApiException):
+ status_code = e.status if isinstance(e.status, int) else None
+ status = f" (HTTP {status_code})" if status_code is not None else
""
+ detail = _api_error_detail(e.body)
+ message = f"Boat could not {operation}{status}."
+ if detail:
+ message = f"{message} {detail}"
+ if status_code in recoverable_statuses:
+ raise SandboxError(message) from e
+ raise SandboxTerminalError(message) from e
+ raise SandboxTerminalError(f"Boat could not {operation}:
{type(e).__name__}.") from e
+
+
+def _bound_text(text: str, max_bytes: int, *, already_truncated: bool = False)
-> tuple[str, bool]:
+ encoded = text.encode("utf-8")
+ if len(encoded) <= max_bytes:
+ return text, already_truncated
+ return encoded[-max_bytes:].decode("utf-8", errors="ignore"), True
+
+
+def _parse_bool(value: Any, name: str) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "1", "yes"}:
+ return True
+ if normalized in {"false", "0", "no"}:
+ return False
+ raise SandboxTerminalError(f"The Boat connection extra {name} must be a
boolean.")
+
+
+class BoatSandboxBackend(SandboxBackend):
+ """
+ Sandbox backend that runs agent commands in a `Boat
<https://docs.boat.dev/quickstart>`__ sandbox.
+
+ Boat (formerly Ascii Box) is a hosted cloud-computer API: the Airflow
worker
+ needs only network access and an API key, with no local daemon or host
+ virtualization. Credentials resolve lazily from an Airflow connection on
+ first use.
+
+ Connection fields: ``password`` is the Boat API key (required). ``host``
may
+ override the API base URL. The extra may set ``timeout`` (request timeout
in
+ seconds) and ``no_env`` (withhold account secrets; default ``true``).
+
+ Boat cannot enforce a deny-all or per-domain egress policy. ``create``
+ therefore refuses a
:class:`~airflow.providers.common.ai.sandbox.SandboxSpec`
+ that asks for ``block_network=True`` or ``allow_egress_to``, preserving the
+ fail-closed contract. Pass ``SandboxSpec(block_network=False)`` (and set
+ that on
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`)
+ when open egress is acceptable.
+
+ Writes use Boat's native file API; reads use the inherited shell
+ implementation, because the native read API takes no size parameter and
+ would land a whole file in worker memory before ``max_bytes`` could reject
+ it.
+
+ :param boat_conn_id: Airflow connection ID for Boat. ``None`` lets the
+ backend read ``BOAT_API_KEY`` (and optional ``BOAT_BASE_URL``) from the
+ environment.
+ :param machine_type: Boat machine size: ``small``, ``default``, or
``large``.
+ Default ``"default"``.
+ :param ttl_seconds: Server-side auto-stop TTL in seconds after which the
+ sandbox is archived even if the worker never destroyed it. Default
``3600``.
+ :param ready_timeout: Seconds to wait for a newly created sandbox to become
+ ready. Default ``300``.
+ :param no_env: When ``True`` (default), create a no-env sandbox that
receives
+ none of the account's stored secrets. ``None`` reads the connection
extra
+ and otherwise defaults to ``True``.
+ """
+
+ name = "boat"
+
+ def __init__(
+ self,
+ boat_conn_id: str | None = "boat_default",
+ *,
+ machine_type: str = "default",
+ ttl_seconds: int = 3600,
+ ready_timeout: float = 300.0,
+ no_env: bool | None = None,
+ ) -> None:
+ if machine_type not in _MACHINE_TYPES:
+ raise ValueError(f"machine_type must be one of
{sorted(_MACHINE_TYPES)}, got {machine_type!r}.")
+ _validate_positive_finite(ttl_seconds, "ttl_seconds")
+ # int() would floor a fractional value, and the API reads 0 as "never
+ # auto-stop" -- silently discarding the only backstop against a leak.
+ if int(ttl_seconds) != ttl_seconds:
+ raise ValueError(f"ttl_seconds must be a whole number of seconds,
got {ttl_seconds!r}.")
+ _validate_positive_finite(ready_timeout, "ready_timeout")
+ self._boat_conn_id = boat_conn_id
+ self._machine_type = machine_type
+ self._ttl_seconds = int(ttl_seconds)
+ self._ready_timeout = ready_timeout
+ self._no_env = no_env
+ self._resolved_no_env = True if no_env is None else no_env
+ self._request_timeout: float | None = None
+ self._api_client: ApiClient | None = None
+ self._boat_api: BoatApi | None = None
+
+ def _get_api(self) -> BoatApi:
+ if self._boat_api is not None:
+ return self._boat_api
+ with _translate_boat_errors("initialize its client"):
+ import os
Review Comment:
Validated on PR head 0476fdaf9a: os is a module-level standard-library
import, while boat_sdk remains lazily imported as the optional dependency
requires.
##########
providers/common/ai/docs/sandbox/backends.rst:
##########
@@ -111,6 +111,66 @@ 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-boat:
+
+Boat (hosted)
+-------------
+
+:class:`~airflow.providers.common.ai.sandbox.boat.BoatSandboxBackend` runs each
+sandbox on `Boat <https://docs.boat.dev/quickstart>`__, the hosted
cloud-computer
+API formerly called Ascii Box. The Airflow worker needs only network access and
+an API key: no local daemon, Docker socket, KVM or nested 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[sandbox-boat]"
+
+.. code-block:: python
+
+ from airflow.providers.common.ai.sandbox import BoatSandboxBackend,
SandboxSpec
+ from airflow.providers.common.ai.toolsets import SandboxToolset
+
+ SandboxToolset(
+ BoatSandboxBackend(boat_conn_id="boat_default"),
+ spec=SandboxSpec(block_network=False),
+ )
+
+Credentials resolve lazily from a generic Airflow connection on first use, so
the
+API key can stay in the configured secrets backend:
+
+- ``password``: Boat API key. Required.
+- ``host``: API base URL. Optional; defaults to ``https://boat.dev/api/v1``.
+- Extra ``timeout``: HTTP request timeout in seconds.
+- Extra ``no_env``: whether Boat should withhold account-stored secrets.
+ Defaults to ``true``.
+
+Constructor parameters:
+
+- ``boat_conn_id``: Connection ID. Default ``"boat_default"``. Passing ``None``
+ reads ``BOAT_API_KEY`` and optional ``BOAT_BASE_URL`` from the worker
+ environment instead.
+- ``machine_type``: ``"small"``, ``"default"`` or ``"large"``.
+- ``ttl_seconds``: server-side auto-stop TTL. Default ``3600``.
+- ``ready_timeout``: provisioning deadline. Default ``300``.
+- ``no_env``: explicit override for the connection's ``no_env`` setting.
+
+``SandboxSpec.env`` is passed when the sandbox is created. **Boat cannot
enforce
+a deny-all network policy or a per-domain egress allowlist**, so the backend
+refuses ``block_network=True`` and ``allow_egress_to`` rather than silently
+provisioning something weaker than the spec asked for. Since ``block_network``
+defaults to ``True``, that includes a bare ``SandboxSpec()``: pass
+``SandboxSpec(block_network=False)`` to state that open egress is acceptable,
or
+use Modal when it is not.
+
+Writes use Boat's native file API. Reads deliberately keep the inherited
bounded
+shell implementation, so ``max_bytes`` is enforced inside the guest before file
+contents reach worker memory. Command timeouts are capped at 600 seconds; a
+sandbox whose command times out, or that never becomes ready, is torn down
+immediately, with the server-side TTL as the orphan-cleanup backstop.
Review Comment:
Validated in the current documentation: Boat TTL is explicitly documented as
server-side archival with the snapshot preserved, not deletion. This outdated
thread remains resolved.
##########
providers/common/ai/src/airflow/providers/common/ai/sandbox/boat.py:
##########
@@ -0,0 +1,492 @@
+# 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.
+"""Boat backend for
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`."""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import math
+import shlex
+from contextlib import contextmanager, suppress
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.common.ai.sandbox.base import (
+ SandboxBackend,
+ SandboxError,
+ SandboxExecResult,
+ SandboxTerminalError,
+ _new_sandbox_name,
+ _validate_positive_finite,
+)
+from airflow.providers.common.compat.sdk import BaseHook
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from boat_sdk import ApiClient
+ from boat_sdk.api.boat_api import BoatApi
+
+ from airflow.providers.common.ai.sandbox.base import SandboxSpec
+
+log = logging.getLogger(__name__)
+
+_DEFAULT_BASE_URL = "https://boat.dev/api/v1"
+_MAX_COMMAND_TIMEOUT = 600
+_MAX_ERROR_DETAIL = 300
+_FILE_OP_TIMEOUT = 120.0
+_HELPER_OUTPUT_CAP = 1024 * 1024
+_READY_STATES = frozenset({"ready", "idle", "running"})
+_MACHINE_TYPES = frozenset({"small", "default", "large"})
+
+
+def _api_error_detail(body: Any) -> str:
+ """
+ Summarize the ``code``/``message`` pair Boat returns in a failed call's
body.
+
+ The status code alone hides reasons the task owner has to act on, such as
an
+ account that has no Boat subscription yet. Returns an empty string when the
+ body is missing or is not the documented JSON error envelope.
+ """
+ if isinstance(body, bytes):
+ with suppress(UnicodeDecodeError):
+ body = body.decode("utf-8")
+ if not isinstance(body, str):
+ return ""
+ try:
+ payload = json.loads(body)
+ except ValueError:
+ return ""
+ if not isinstance(payload, dict):
+ return ""
+ error = payload.get("error")
+ if not isinstance(error, dict):
+ error = payload
+ parts = [
+ value.strip()
+ for key in ("code", "message")
+ if isinstance(value := error.get(key), str) and value.strip()
+ ]
+ if len(parts) == 2 and parts[0] == parts[1]:
+ del parts[1]
+ detail = ": ".join(parts)
+ return detail[:_MAX_ERROR_DETAIL]
+
+
+@contextmanager
+def _translate_boat_errors(
+ operation: str, *, recoverable_statuses: frozenset[int] = frozenset()
+) -> Iterator[None]:
+ try:
+ yield
+ except SandboxError:
+ raise
+ except Exception as e:
+ try:
+ from boat_sdk.exceptions import ApiException
+ except ImportError:
+ raise SandboxTerminalError(
+ 'The Boat SDK is not installed. Install
"apache-airflow-providers-common-ai[sandbox-boat]".'
+ ) from e
+ if isinstance(e, ApiException):
+ status_code = e.status if isinstance(e.status, int) else None
+ status = f" (HTTP {status_code})" if status_code is not None else
""
+ detail = _api_error_detail(e.body)
+ message = f"Boat could not {operation}{status}."
+ if detail:
+ message = f"{message} {detail}"
+ if status_code in recoverable_statuses:
+ raise SandboxError(message) from e
+ raise SandboxTerminalError(message) from e
+ raise SandboxTerminalError(f"Boat could not {operation}:
{type(e).__name__}.") from e
+
+
+def _bound_text(text: str, max_bytes: int, *, already_truncated: bool = False)
-> tuple[str, bool]:
+ encoded = text.encode("utf-8")
+ if len(encoded) <= max_bytes:
+ return text, already_truncated
+ return encoded[-max_bytes:].decode("utf-8", errors="ignore"), True
+
+
+def _parse_bool(value: Any, name: str) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "1", "yes"}:
+ return True
+ if normalized in {"false", "0", "no"}:
+ return False
+ raise SandboxTerminalError(f"The Boat connection extra {name} must be a
boolean.")
+
+
+class BoatSandboxBackend(SandboxBackend):
+ """
+ Sandbox backend that runs agent commands in a `Boat
<https://docs.boat.dev/quickstart>`__ sandbox.
+
+ Boat (formerly Ascii Box) is a hosted cloud-computer API: the Airflow
worker
+ needs only network access and an API key, with no local daemon or host
+ virtualization. Credentials resolve lazily from an Airflow connection on
+ first use.
+
+ Connection fields: ``password`` is the Boat API key (required). ``host``
may
+ override the API base URL. The extra may set ``timeout`` (request timeout
in
+ seconds) and ``no_env`` (withhold account secrets; default ``true``).
+
+ Boat cannot enforce a deny-all or per-domain egress policy. ``create``
+ therefore refuses a
:class:`~airflow.providers.common.ai.sandbox.SandboxSpec`
+ that asks for ``block_network=True`` or ``allow_egress_to``, preserving the
+ fail-closed contract. Pass ``SandboxSpec(block_network=False)`` (and set
+ that on
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`)
+ when open egress is acceptable.
+
+ Writes use Boat's native file API; reads use the inherited shell
+ implementation, because the native read API takes no size parameter and
+ would land a whole file in worker memory before ``max_bytes`` could reject
+ it.
+
+ :param boat_conn_id: Airflow connection ID for Boat. ``None`` lets the
+ backend read ``BOAT_API_KEY`` (and optional ``BOAT_BASE_URL``) from the
+ environment.
+ :param machine_type: Boat machine size: ``small``, ``default``, or
``large``.
+ Default ``"default"``.
+ :param ttl_seconds: Server-side auto-stop TTL in seconds after which the
+ sandbox is archived even if the worker never destroyed it. Default
``3600``.
+ :param ready_timeout: Seconds to wait for a newly created sandbox to become
+ ready. Default ``300``.
+ :param no_env: When ``True`` (default), create a no-env sandbox that
receives
+ none of the account's stored secrets. ``None`` reads the connection
extra
+ and otherwise defaults to ``True``.
+ """
+
+ name = "boat"
+
+ def __init__(
+ self,
+ boat_conn_id: str | None = "boat_default",
+ *,
+ machine_type: str = "default",
+ ttl_seconds: int = 3600,
+ ready_timeout: float = 300.0,
+ no_env: bool | None = None,
+ ) -> None:
+ if machine_type not in _MACHINE_TYPES:
+ raise ValueError(f"machine_type must be one of
{sorted(_MACHINE_TYPES)}, got {machine_type!r}.")
+ _validate_positive_finite(ttl_seconds, "ttl_seconds")
+ # int() would floor a fractional value, and the API reads 0 as "never
+ # auto-stop" -- silently discarding the only backstop against a leak.
+ if int(ttl_seconds) != ttl_seconds:
+ raise ValueError(f"ttl_seconds must be a whole number of seconds,
got {ttl_seconds!r}.")
+ _validate_positive_finite(ready_timeout, "ready_timeout")
+ self._boat_conn_id = boat_conn_id
+ self._machine_type = machine_type
+ self._ttl_seconds = int(ttl_seconds)
+ self._ready_timeout = ready_timeout
+ self._no_env = no_env
+ self._resolved_no_env = True if no_env is None else no_env
+ self._request_timeout: float | None = None
+ self._api_client: ApiClient | None = None
+ self._boat_api: BoatApi | None = None
+
+ def _get_api(self) -> BoatApi:
+ if self._boat_api is not None:
+ return self._boat_api
+ with _translate_boat_errors("initialize its client"):
+ import os
+
+ from boat_sdk import ApiClient, Configuration
+ from boat_sdk.api.boat_api import BoatApi
+
+ if self._boat_conn_id is None:
+ api_key = (os.environ.get("BOAT_API_KEY") or "").strip()
+ if not api_key:
+ raise SandboxTerminalError(
+ "BOAT_API_KEY is not set; export it or pass an Airflow
connection id."
+ )
+ base_url = (os.environ.get("BOAT_BASE_URL") or
_DEFAULT_BASE_URL).rstrip("/")
+ request_timeout = 30.0
+ no_env = True if self._no_env is None else self._no_env
+ else:
+ conn = BaseHook.get_connection(self._boat_conn_id)
+ api_key = (conn.password or "").strip()
+ if not api_key:
+ raise SandboxTerminalError(
+ f"Connection {self._boat_conn_id!r} has no password;
set it to the Boat API key."
+ )
+ base_url = (conn.host or _DEFAULT_BASE_URL).rstrip("/")
+ if not base_url.startswith("http"):
+ base_url = f"https://{base_url}"
+ extra = conn.extra_dejson
+ request_timeout = extra.get("timeout", 30)
+ try:
+ request_timeout = float(request_timeout)
+ _validate_positive_finite(request_timeout, "connection
extra timeout")
+ except (TypeError, ValueError) as e:
+ raise SandboxTerminalError(
+ "The Boat connection extra timeout must be a positive
finite number."
+ ) from e
+ if self._no_env is None:
+ no_env = _parse_bool(extra.get("no_env", True), "no_env")
+ else:
+ no_env = self._no_env
+
+ self._request_timeout = request_timeout
+ self._resolved_no_env = no_env
+ self._api_client = ApiClient(Configuration(host=base_url,
access_token=api_key))
+ self._boat_api = BoatApi(self._api_client)
+ return self._boat_api
+
+ def _http_timeout(self, seconds: float) -> float:
+ configured = self._request_timeout if self._request_timeout is not
None else 30.0
+ return max(configured, seconds + 30.0)
+
+ def _wait_until_ready(self, sandbox_id: str) -> None:
+ from boat_sdk import wait_until_ready
+
+ with _translate_boat_errors("wait for a sandbox to become ready"):
+ wait_until_ready(
+ self._get_api(),
+ sandbox_id,
+ timeout_seconds=self._ready_timeout,
+ poll_interval_seconds=2.0,
+ )
+
+ def create(self, *, spec: SandboxSpec | None = None) -> str:
+ if spec is not None and spec.allow_egress_to:
Review Comment:
Validated on PR head 0476fdaf9a: create rejects both allow_egress_to and
allow_egress_to_cidrs before provisioning, with dedicated unit coverage. The
complete Boat suite passes 43/43.
##########
providers/common/ai/src/airflow/providers/common/ai/sandbox/boat.py:
##########
@@ -0,0 +1,492 @@
+# 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.
+"""Boat backend for
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`."""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import math
+import shlex
+from contextlib import contextmanager, suppress
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.common.ai.sandbox.base import (
+ SandboxBackend,
+ SandboxError,
+ SandboxExecResult,
+ SandboxTerminalError,
+ _new_sandbox_name,
+ _validate_positive_finite,
+)
+from airflow.providers.common.compat.sdk import BaseHook
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from boat_sdk import ApiClient
+ from boat_sdk.api.boat_api import BoatApi
+
+ from airflow.providers.common.ai.sandbox.base import SandboxSpec
+
+log = logging.getLogger(__name__)
+
+_DEFAULT_BASE_URL = "https://boat.dev/api/v1"
+_MAX_COMMAND_TIMEOUT = 600
+_MAX_ERROR_DETAIL = 300
+_FILE_OP_TIMEOUT = 120.0
+_HELPER_OUTPUT_CAP = 1024 * 1024
+_READY_STATES = frozenset({"ready", "idle", "running"})
+_MACHINE_TYPES = frozenset({"small", "default", "large"})
+
+
+def _api_error_detail(body: Any) -> str:
+ """
+ Summarize the ``code``/``message`` pair Boat returns in a failed call's
body.
+
+ The status code alone hides reasons the task owner has to act on, such as
an
+ account that has no Boat subscription yet. Returns an empty string when the
+ body is missing or is not the documented JSON error envelope.
+ """
+ if isinstance(body, bytes):
+ with suppress(UnicodeDecodeError):
+ body = body.decode("utf-8")
+ if not isinstance(body, str):
+ return ""
+ try:
+ payload = json.loads(body)
+ except ValueError:
+ return ""
+ if not isinstance(payload, dict):
+ return ""
+ error = payload.get("error")
+ if not isinstance(error, dict):
+ error = payload
+ parts = [
+ value.strip()
+ for key in ("code", "message")
+ if isinstance(value := error.get(key), str) and value.strip()
+ ]
+ if len(parts) == 2 and parts[0] == parts[1]:
+ del parts[1]
+ detail = ": ".join(parts)
+ return detail[:_MAX_ERROR_DETAIL]
+
+
+@contextmanager
+def _translate_boat_errors(
+ operation: str, *, recoverable_statuses: frozenset[int] = frozenset()
+) -> Iterator[None]:
+ try:
+ yield
+ except SandboxError:
+ raise
+ except Exception as e:
+ try:
+ from boat_sdk.exceptions import ApiException
+ except ImportError:
+ raise SandboxTerminalError(
+ 'The Boat SDK is not installed. Install
"apache-airflow-providers-common-ai[sandbox-boat]".'
+ ) from e
+ if isinstance(e, ApiException):
+ status_code = e.status if isinstance(e.status, int) else None
+ status = f" (HTTP {status_code})" if status_code is not None else
""
+ detail = _api_error_detail(e.body)
+ message = f"Boat could not {operation}{status}."
+ if detail:
+ message = f"{message} {detail}"
+ if status_code in recoverable_statuses:
+ raise SandboxError(message) from e
+ raise SandboxTerminalError(message) from e
+ raise SandboxTerminalError(f"Boat could not {operation}:
{type(e).__name__}.") from e
+
+
+def _bound_text(text: str, max_bytes: int, *, already_truncated: bool = False)
-> tuple[str, bool]:
+ encoded = text.encode("utf-8")
+ if len(encoded) <= max_bytes:
+ return text, already_truncated
+ return encoded[-max_bytes:].decode("utf-8", errors="ignore"), True
+
+
+def _parse_bool(value: Any, name: str) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "1", "yes"}:
+ return True
+ if normalized in {"false", "0", "no"}:
+ return False
+ raise SandboxTerminalError(f"The Boat connection extra {name} must be a
boolean.")
+
+
+class BoatSandboxBackend(SandboxBackend):
+ """
+ Sandbox backend that runs agent commands in a `Boat
<https://docs.boat.dev/quickstart>`__ sandbox.
+
+ Boat (formerly Ascii Box) is a hosted cloud-computer API: the Airflow
worker
+ needs only network access and an API key, with no local daemon or host
+ virtualization. Credentials resolve lazily from an Airflow connection on
+ first use.
+
+ Connection fields: ``password`` is the Boat API key (required). ``host``
may
+ override the API base URL. The extra may set ``timeout`` (request timeout
in
+ seconds) and ``no_env`` (withhold account secrets; default ``true``).
+
+ Boat cannot enforce a deny-all or per-domain egress policy. ``create``
+ therefore refuses a
:class:`~airflow.providers.common.ai.sandbox.SandboxSpec`
+ that asks for ``block_network=True`` or ``allow_egress_to``, preserving the
+ fail-closed contract. Pass ``SandboxSpec(block_network=False)`` (and set
+ that on
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`)
+ when open egress is acceptable.
+
+ Writes use Boat's native file API; reads use the inherited shell
+ implementation, because the native read API takes no size parameter and
+ would land a whole file in worker memory before ``max_bytes`` could reject
+ it.
+
+ :param boat_conn_id: Airflow connection ID for Boat. ``None`` lets the
+ backend read ``BOAT_API_KEY`` (and optional ``BOAT_BASE_URL``) from the
+ environment.
+ :param machine_type: Boat machine size: ``small``, ``default``, or
``large``.
+ Default ``"default"``.
+ :param ttl_seconds: Server-side auto-stop TTL in seconds after which the
+ sandbox is archived even if the worker never destroyed it. Default
``3600``.
+ :param ready_timeout: Seconds to wait for a newly created sandbox to become
+ ready. Default ``300``.
+ :param no_env: When ``True`` (default), create a no-env sandbox that
receives
+ none of the account's stored secrets. ``None`` reads the connection
extra
+ and otherwise defaults to ``True``.
+ """
+
+ name = "boat"
+
+ def __init__(
+ self,
+ boat_conn_id: str | None = "boat_default",
+ *,
+ machine_type: str = "default",
+ ttl_seconds: int = 3600,
+ ready_timeout: float = 300.0,
+ no_env: bool | None = None,
+ ) -> None:
+ if machine_type not in _MACHINE_TYPES:
+ raise ValueError(f"machine_type must be one of
{sorted(_MACHINE_TYPES)}, got {machine_type!r}.")
+ _validate_positive_finite(ttl_seconds, "ttl_seconds")
+ # int() would floor a fractional value, and the API reads 0 as "never
+ # auto-stop" -- silently discarding the only backstop against a leak.
+ if int(ttl_seconds) != ttl_seconds:
+ raise ValueError(f"ttl_seconds must be a whole number of seconds,
got {ttl_seconds!r}.")
+ _validate_positive_finite(ready_timeout, "ready_timeout")
+ self._boat_conn_id = boat_conn_id
+ self._machine_type = machine_type
+ self._ttl_seconds = int(ttl_seconds)
+ self._ready_timeout = ready_timeout
+ self._no_env = no_env
+ self._resolved_no_env = True if no_env is None else no_env
+ self._request_timeout: float | None = None
+ self._api_client: ApiClient | None = None
+ self._boat_api: BoatApi | None = None
+
+ def _get_api(self) -> BoatApi:
+ if self._boat_api is not None:
+ return self._boat_api
+ with _translate_boat_errors("initialize its client"):
+ import os
+
+ from boat_sdk import ApiClient, Configuration
+ from boat_sdk.api.boat_api import BoatApi
+
+ if self._boat_conn_id is None:
+ api_key = (os.environ.get("BOAT_API_KEY") or "").strip()
+ if not api_key:
+ raise SandboxTerminalError(
+ "BOAT_API_KEY is not set; export it or pass an Airflow
connection id."
+ )
+ base_url = (os.environ.get("BOAT_BASE_URL") or
_DEFAULT_BASE_URL).rstrip("/")
+ request_timeout = 30.0
+ no_env = True if self._no_env is None else self._no_env
+ else:
+ conn = BaseHook.get_connection(self._boat_conn_id)
+ api_key = (conn.password or "").strip()
+ if not api_key:
+ raise SandboxTerminalError(
+ f"Connection {self._boat_conn_id!r} has no password;
set it to the Boat API key."
+ )
+ base_url = (conn.host or _DEFAULT_BASE_URL).rstrip("/")
+ if not base_url.startswith("http"):
+ base_url = f"https://{base_url}"
+ extra = conn.extra_dejson
+ request_timeout = extra.get("timeout", 30)
+ try:
+ request_timeout = float(request_timeout)
+ _validate_positive_finite(request_timeout, "connection
extra timeout")
+ except (TypeError, ValueError) as e:
+ raise SandboxTerminalError(
+ "The Boat connection extra timeout must be a positive
finite number."
+ ) from e
+ if self._no_env is None:
+ no_env = _parse_bool(extra.get("no_env", True), "no_env")
+ else:
+ no_env = self._no_env
+
+ self._request_timeout = request_timeout
+ self._resolved_no_env = no_env
+ self._api_client = ApiClient(Configuration(host=base_url,
access_token=api_key))
+ self._boat_api = BoatApi(self._api_client)
+ return self._boat_api
+
+ def _http_timeout(self, seconds: float) -> float:
+ configured = self._request_timeout if self._request_timeout is not
None else 30.0
+ return max(configured, seconds + 30.0)
+
+ def _wait_until_ready(self, sandbox_id: str) -> None:
+ from boat_sdk import wait_until_ready
+
+ with _translate_boat_errors("wait for a sandbox to become ready"):
+ wait_until_ready(
+ self._get_api(),
+ sandbox_id,
+ timeout_seconds=self._ready_timeout,
+ poll_interval_seconds=2.0,
+ )
+
+ def create(self, *, spec: SandboxSpec | None = None) -> str:
+ if spec is not None and spec.allow_egress_to:
+ raise SandboxTerminalError(
+ "The Boat backend cannot apply a per-domain egress allowlist. "
+ "Drop allow_egress_to, or use a backend with per-domain
network rules."
+ )
+ if spec is not None and spec.block_network:
+ raise SandboxTerminalError(
+ "The Boat backend cannot deny outbound network access. Pass "
+ "SandboxSpec(block_network=False) when open egress is
acceptable, or "
+ "use a backend that can enforce a deny-all policy."
+ )
+
+ api = self._get_api()
+ with _translate_boat_errors("create a sandbox"):
+ from boat_sdk.models.create_sandbox_request import
CreateSandboxRequest
+
+ # The generated request models are typed by their wire aliases.
+ created = api.create(
+ create_sandbox_request=CreateSandboxRequest(
+ type=self._machine_type,
+ ttlSeconds=self._ttl_seconds,
+ noEnv=self._resolved_no_env,
+ env=dict(spec.env) if spec is not None and spec.env else
None,
Review Comment:
Validated on PR head 0476fdaf9a: SandboxSpec.env is retained per sandbox and
exported with env.items() after Boat login-shell initialization. The
environment-specific test and full Boat suite pass; the prior iteration
regression is fixed in 0476fdaf9a.
##########
providers/common/ai/src/airflow/providers/common/ai/sandbox/boat.py:
##########
@@ -0,0 +1,492 @@
+# 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.
+"""Boat backend for
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`."""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import math
+import shlex
+from contextlib import contextmanager, suppress
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.common.ai.sandbox.base import (
+ SandboxBackend,
+ SandboxError,
+ SandboxExecResult,
+ SandboxTerminalError,
+ _new_sandbox_name,
+ _validate_positive_finite,
+)
+from airflow.providers.common.compat.sdk import BaseHook
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from boat_sdk import ApiClient
+ from boat_sdk.api.boat_api import BoatApi
+
+ from airflow.providers.common.ai.sandbox.base import SandboxSpec
+
+log = logging.getLogger(__name__)
+
+_DEFAULT_BASE_URL = "https://boat.dev/api/v1"
+_MAX_COMMAND_TIMEOUT = 600
+_MAX_ERROR_DETAIL = 300
+_FILE_OP_TIMEOUT = 120.0
+_HELPER_OUTPUT_CAP = 1024 * 1024
+_READY_STATES = frozenset({"ready", "idle", "running"})
+_MACHINE_TYPES = frozenset({"small", "default", "large"})
+
+
+def _api_error_detail(body: Any) -> str:
+ """
+ Summarize the ``code``/``message`` pair Boat returns in a failed call's
body.
+
+ The status code alone hides reasons the task owner has to act on, such as
an
+ account that has no Boat subscription yet. Returns an empty string when the
+ body is missing or is not the documented JSON error envelope.
+ """
+ if isinstance(body, bytes):
+ with suppress(UnicodeDecodeError):
+ body = body.decode("utf-8")
+ if not isinstance(body, str):
+ return ""
+ try:
+ payload = json.loads(body)
+ except ValueError:
+ return ""
+ if not isinstance(payload, dict):
+ return ""
+ error = payload.get("error")
+ if not isinstance(error, dict):
+ error = payload
+ parts = [
+ value.strip()
+ for key in ("code", "message")
+ if isinstance(value := error.get(key), str) and value.strip()
+ ]
+ if len(parts) == 2 and parts[0] == parts[1]:
+ del parts[1]
+ detail = ": ".join(parts)
+ return detail[:_MAX_ERROR_DETAIL]
+
+
+@contextmanager
+def _translate_boat_errors(
+ operation: str, *, recoverable_statuses: frozenset[int] = frozenset()
+) -> Iterator[None]:
+ try:
+ yield
+ except SandboxError:
+ raise
+ except Exception as e:
+ try:
+ from boat_sdk.exceptions import ApiException
+ except ImportError:
+ raise SandboxTerminalError(
+ 'The Boat SDK is not installed. Install
"apache-airflow-providers-common-ai[sandbox-boat]".'
+ ) from e
+ if isinstance(e, ApiException):
+ status_code = e.status if isinstance(e.status, int) else None
+ status = f" (HTTP {status_code})" if status_code is not None else
""
+ detail = _api_error_detail(e.body)
+ message = f"Boat could not {operation}{status}."
+ if detail:
+ message = f"{message} {detail}"
+ if status_code in recoverable_statuses:
+ raise SandboxError(message) from e
+ raise SandboxTerminalError(message) from e
+ raise SandboxTerminalError(f"Boat could not {operation}:
{type(e).__name__}.") from e
+
+
+def _bound_text(text: str, max_bytes: int, *, already_truncated: bool = False)
-> tuple[str, bool]:
+ encoded = text.encode("utf-8")
+ if len(encoded) <= max_bytes:
+ return text, already_truncated
+ return encoded[-max_bytes:].decode("utf-8", errors="ignore"), True
+
+
+def _parse_bool(value: Any, name: str) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "1", "yes"}:
+ return True
+ if normalized in {"false", "0", "no"}:
+ return False
+ raise SandboxTerminalError(f"The Boat connection extra {name} must be a
boolean.")
+
+
+class BoatSandboxBackend(SandboxBackend):
+ """
+ Sandbox backend that runs agent commands in a `Boat
<https://docs.boat.dev/quickstart>`__ sandbox.
+
+ Boat (formerly Ascii Box) is a hosted cloud-computer API: the Airflow
worker
+ needs only network access and an API key, with no local daemon or host
+ virtualization. Credentials resolve lazily from an Airflow connection on
+ first use.
+
+ Connection fields: ``password`` is the Boat API key (required). ``host``
may
+ override the API base URL. The extra may set ``timeout`` (request timeout
in
+ seconds) and ``no_env`` (withhold account secrets; default ``true``).
+
+ Boat cannot enforce a deny-all or per-domain egress policy. ``create``
+ therefore refuses a
:class:`~airflow.providers.common.ai.sandbox.SandboxSpec`
+ that asks for ``block_network=True`` or ``allow_egress_to``, preserving the
+ fail-closed contract. Pass ``SandboxSpec(block_network=False)`` (and set
+ that on
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`)
+ when open egress is acceptable.
+
+ Writes use Boat's native file API; reads use the inherited shell
+ implementation, because the native read API takes no size parameter and
+ would land a whole file in worker memory before ``max_bytes`` could reject
+ it.
+
+ :param boat_conn_id: Airflow connection ID for Boat. ``None`` lets the
+ backend read ``BOAT_API_KEY`` (and optional ``BOAT_BASE_URL``) from the
+ environment.
+ :param machine_type: Boat machine size: ``small``, ``default``, or
``large``.
+ Default ``"default"``.
+ :param ttl_seconds: Server-side auto-stop TTL in seconds after which the
+ sandbox is archived even if the worker never destroyed it. Default
``3600``.
+ :param ready_timeout: Seconds to wait for a newly created sandbox to become
+ ready. Default ``300``.
+ :param no_env: When ``True`` (default), create a no-env sandbox that
receives
+ none of the account's stored secrets. ``None`` reads the connection
extra
+ and otherwise defaults to ``True``.
+ """
+
+ name = "boat"
+
+ def __init__(
+ self,
+ boat_conn_id: str | None = "boat_default",
+ *,
+ machine_type: str = "default",
+ ttl_seconds: int = 3600,
+ ready_timeout: float = 300.0,
+ no_env: bool | None = None,
+ ) -> None:
+ if machine_type not in _MACHINE_TYPES:
+ raise ValueError(f"machine_type must be one of
{sorted(_MACHINE_TYPES)}, got {machine_type!r}.")
+ _validate_positive_finite(ttl_seconds, "ttl_seconds")
+ # int() would floor a fractional value, and the API reads 0 as "never
+ # auto-stop" -- silently discarding the only backstop against a leak.
+ if int(ttl_seconds) != ttl_seconds:
+ raise ValueError(f"ttl_seconds must be a whole number of seconds,
got {ttl_seconds!r}.")
+ _validate_positive_finite(ready_timeout, "ready_timeout")
+ self._boat_conn_id = boat_conn_id
+ self._machine_type = machine_type
+ self._ttl_seconds = int(ttl_seconds)
+ self._ready_timeout = ready_timeout
+ self._no_env = no_env
+ self._resolved_no_env = True if no_env is None else no_env
+ self._request_timeout: float | None = None
+ self._api_client: ApiClient | None = None
+ self._boat_api: BoatApi | None = None
+
+ def _get_api(self) -> BoatApi:
+ if self._boat_api is not None:
+ return self._boat_api
+ with _translate_boat_errors("initialize its client"):
+ import os
+
+ from boat_sdk import ApiClient, Configuration
+ from boat_sdk.api.boat_api import BoatApi
+
+ if self._boat_conn_id is None:
+ api_key = (os.environ.get("BOAT_API_KEY") or "").strip()
+ if not api_key:
+ raise SandboxTerminalError(
+ "BOAT_API_KEY is not set; export it or pass an Airflow
connection id."
+ )
+ base_url = (os.environ.get("BOAT_BASE_URL") or
_DEFAULT_BASE_URL).rstrip("/")
+ request_timeout = 30.0
+ no_env = True if self._no_env is None else self._no_env
+ else:
+ conn = BaseHook.get_connection(self._boat_conn_id)
+ api_key = (conn.password or "").strip()
+ if not api_key:
+ raise SandboxTerminalError(
+ f"Connection {self._boat_conn_id!r} has no password;
set it to the Boat API key."
+ )
+ base_url = (conn.host or _DEFAULT_BASE_URL).rstrip("/")
+ if not base_url.startswith("http"):
+ base_url = f"https://{base_url}"
+ extra = conn.extra_dejson
+ request_timeout = extra.get("timeout", 30)
+ try:
+ request_timeout = float(request_timeout)
+ _validate_positive_finite(request_timeout, "connection
extra timeout")
+ except (TypeError, ValueError) as e:
+ raise SandboxTerminalError(
+ "The Boat connection extra timeout must be a positive
finite number."
+ ) from e
+ if self._no_env is None:
+ no_env = _parse_bool(extra.get("no_env", True), "no_env")
+ else:
+ no_env = self._no_env
+
+ self._request_timeout = request_timeout
+ self._resolved_no_env = no_env
+ self._api_client = ApiClient(Configuration(host=base_url,
access_token=api_key))
+ self._boat_api = BoatApi(self._api_client)
+ return self._boat_api
+
+ def _http_timeout(self, seconds: float) -> float:
+ configured = self._request_timeout if self._request_timeout is not
None else 30.0
+ return max(configured, seconds + 30.0)
+
+ def _wait_until_ready(self, sandbox_id: str) -> None:
+ from boat_sdk import wait_until_ready
+
+ with _translate_boat_errors("wait for a sandbox to become ready"):
+ wait_until_ready(
+ self._get_api(),
+ sandbox_id,
+ timeout_seconds=self._ready_timeout,
+ poll_interval_seconds=2.0,
+ )
+
+ def create(self, *, spec: SandboxSpec | None = None) -> str:
+ if spec is not None and spec.allow_egress_to:
+ raise SandboxTerminalError(
+ "The Boat backend cannot apply a per-domain egress allowlist. "
+ "Drop allow_egress_to, or use a backend with per-domain
network rules."
+ )
+ if spec is not None and spec.block_network:
+ raise SandboxTerminalError(
+ "The Boat backend cannot deny outbound network access. Pass "
+ "SandboxSpec(block_network=False) when open egress is
acceptable, or "
+ "use a backend that can enforce a deny-all policy."
+ )
+
+ api = self._get_api()
+ with _translate_boat_errors("create a sandbox"):
+ from boat_sdk.models.create_sandbox_request import
CreateSandboxRequest
+
+ # The generated request models are typed by their wire aliases.
+ created = api.create(
+ create_sandbox_request=CreateSandboxRequest(
+ type=self._machine_type,
+ ttlSeconds=self._ttl_seconds,
+ noEnv=self._resolved_no_env,
+ env=dict(spec.env) if spec is not None and spec.env else
None,
+ ),
+ _request_timeout=self._http_timeout(self._ready_timeout),
+ )
+ sandbox_id = created.sandbox.id
+ try:
+ self._name_sandbox(sandbox_id)
+ self._wait_until_ready(sandbox_id)
+ except BaseException:
+ # The id has not reached the toolset yet, so nothing else can tear
+ # this sandbox down. The server-side TTL would archive it
eventually,
+ # but that leaves a billed machine idling for an hour by default.
+ with suppress(Exception):
+ self.destroy(sandbox_id)
+ raise
+ return sandbox_id
+
+ def _name_sandbox(self, sandbox_id: str) -> None:
+ """
+ Best-effort rename to the ``airflow-sandbox-`` prefix used for
correlation.
+
+ Failing to name a sandbox costs nothing at run time, so it must not
fail
+ the create -- but it does cost an operator sweeping for orphans later,
+ which is why it is logged rather than silently dropped.
+ """
+ from boat_sdk.models.update_sandbox_request import UpdateSandboxRequest
+
+ try:
+ self._get_api().update(
+ sandbox_id,
+ UpdateSandboxRequest(name=_new_sandbox_name()),
+ _request_timeout=self._http_timeout(_FILE_OP_TIMEOUT),
+ )
+ except Exception:
+ log.warning(
+ "Could not name Boat sandbox %s; it keeps its server-assigned
name and will not "
+ "match an airflow-sandbox-* orphan sweep.",
+ sandbox_id,
+ exc_info=True,
+ )
+
+ def _destroy_after_timeout(self, sandbox: str) -> None:
+ try:
+ self.destroy(sandbox)
+ except SandboxError as e:
+ raise SandboxTerminalError(
+ "The Boat command timed out and deletion of its sandbox could
not be confirmed."
+ ) from e
+
+ 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")
+ if timeout > _MAX_COMMAND_TIMEOUT:
+ raise SandboxTerminalError(
+ f"Boat commands are capped at {_MAX_COMMAND_TIMEOUT} seconds;
got timeout={timeout}."
+ )
+ timeout_seconds = max(1, math.ceil(timeout))
+ api = self._get_api()
+ with _translate_boat_errors("run a sandbox command"):
+ from boat_sdk.models.command_request import CommandRequest
+ from boat_sdk.models.command_response import CommandResponse
+
+ response = api.command(
+ sandbox,
+ CommandRequest(command=command,
timeoutSeconds=timeout_seconds),
Review Comment:
Validated on PR head 0476fdaf9a: the command wrapper redirects both streams
to guest files, waits only for the foreground process, restores output, and
preserves its exit status. The full Boat unit suite passes 43/43.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]