omnigent-ai--omnigent
584 行
22 KiB
Python
584 行
22 KiB
Python
"""
|
|
Islo sandbox launcher.
|
|
|
|
Implements :class:`~omnigent.onboarding.sandboxes.base.SandboxLauncher`
|
|
for `Islo <https://islo.dev>`_ sandboxes. The integration talks to the
|
|
Islo HTTP API directly through ``httpx`` (already a base Omnigent
|
|
dependency), so there is no provider SDK extra to install.
|
|
|
|
Platform notes that shape this launcher:
|
|
|
|
- **API-key auth.** ``ISLO_API_KEY`` is exchanged for a short-lived
|
|
session token via ``POST /auth/token``. The token is cached until
|
|
shortly before expiry, mirroring Islo's Go SDK.
|
|
- **Prebaked host image.** Like Modal and Daytona, sandboxes boot from
|
|
the official Omnigent host image unless overridden. That keeps
|
|
server-managed launches fast.
|
|
- **No local port forwarding.** Islo can run commands and upload files
|
|
through its API, but it does not provide a local-to-sandbox port
|
|
forward for the in-sandbox App OAuth callback. The CLI therefore
|
|
skips that auth step automatically, just as it does for Modal and
|
|
Daytona.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import queue
|
|
import re
|
|
import shlex
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections.abc import Callable, Iterator, Sequence
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
from urllib.parse import quote, urlencode
|
|
|
|
import click
|
|
import httpx
|
|
|
|
from omnigent.onboarding.sandboxes.base import (
|
|
DEFAULT_HOST_IMAGE,
|
|
RemoteCommandResult,
|
|
RemoteProcess,
|
|
SandboxLauncher,
|
|
host_image_wheel_install_command,
|
|
)
|
|
|
|
API_BASE_URL_ENV_VAR: str = "ISLO_BASE_URL"
|
|
"""Optional Islo API base URL override. Defaults to
|
|
``https://api.islo.dev``."""
|
|
|
|
API_KEY_ENV_VAR: str = "ISLO_API_KEY"
|
|
"""Islo API key read from the server/CLI process environment."""
|
|
|
|
HOST_IMAGE_ENV_VAR: str = "OMNIGENT_ISLO_HOST_IMAGE"
|
|
"""Environment variable overriding :data:`DEFAULT_HOST_IMAGE` for Islo
|
|
sandboxes."""
|
|
|
|
SANDBOX_ENV_PASSTHROUGH_ENV_VAR: str = "OMNIGENT_ISLO_SANDBOX_ENV"
|
|
"""Comma-separated server-process environment variable names injected
|
|
into created Islo sandboxes."""
|
|
|
|
_DEFAULT_BASE_URL = "https://api.islo.dev"
|
|
_TOKEN_REFRESH_MARGIN_S = 60.0
|
|
_SANDBOX_CPU = 2
|
|
_SANDBOX_MEMORY_MB = 4096
|
|
_REQUEST_TIMEOUT_S = 30.0
|
|
|
|
# Claude credentials a user injects via sandbox env passthrough that must win
|
|
# over the gateway ``apiKeyHelper`` Islo pre-seeds into every sandbox. When one
|
|
# is present we strip the seeded helper (see
|
|
# :meth:`IsloSandboxLauncher._clear_seeded_api_key_helper`).
|
|
_USER_CLAUDE_CRED_ENV_VARS = ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY")
|
|
|
|
# In-sandbox script that removes a seeded ``apiKeyHelper`` from Claude Code's
|
|
# settings. Best effort: a sandbox without the settings file is left untouched.
|
|
_CLEAR_API_KEY_HELPER_SCRIPT = """\
|
|
import json, os
|
|
path = os.path.expanduser("~/.claude/settings.json")
|
|
try:
|
|
with open(path) as handle:
|
|
settings = json.load(handle)
|
|
except (FileNotFoundError, ValueError):
|
|
raise SystemExit(0)
|
|
if isinstance(settings, dict) and settings.pop("apiKeyHelper", None) is not None:
|
|
with open(path, "w") as handle:
|
|
json.dump(settings, handle, indent=2)
|
|
"""
|
|
|
|
|
|
class _IsloAPIError(RuntimeError):
|
|
"""Provider-boundary error with a user-facing message."""
|
|
|
|
|
|
class _IsloClient:
|
|
"""Small synchronous Islo HTTP API client."""
|
|
|
|
def __init__(self, *, base_url: str, api_key: str) -> None:
|
|
self._base_url = base_url.rstrip("/")
|
|
self._api_key = api_key
|
|
self._client = httpx.Client(timeout=_REQUEST_TIMEOUT_S)
|
|
self._token: str | None = None
|
|
self._token_expires_at = 0.0
|
|
|
|
def close(self) -> None:
|
|
"""Close the underlying HTTP connection pool."""
|
|
self._client.close()
|
|
|
|
def create_sandbox(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Create a sandbox and return the response object."""
|
|
return self._request_json("POST", "/sandboxes/", json=payload)
|
|
|
|
def get_sandbox(self, name: str) -> dict[str, Any]:
|
|
"""Fetch a sandbox by name."""
|
|
return self._request_json("GET", f"/sandboxes/{_url_component(name)}")
|
|
|
|
def delete_sandbox(self, name: str) -> None:
|
|
"""Delete a sandbox by name. Missing sandboxes are treated as gone."""
|
|
try:
|
|
self._request("DELETE", f"/sandboxes/{_url_component(name)}")
|
|
except _IsloAPIError as exc:
|
|
if "HTTP 404" not in str(exc):
|
|
raise
|
|
|
|
def upload_file(self, name: str, local_path: Path, remote_path: str) -> None:
|
|
"""Upload one file to an absolute path in the sandbox."""
|
|
params = urlencode({"path": remote_path})
|
|
endpoint = f"/sandboxes/{_url_component(name)}/files?{params}"
|
|
with local_path.open("rb") as file_obj:
|
|
files = {"file": (local_path.name, file_obj, "application/octet-stream")}
|
|
self._request("POST", endpoint, files=files)
|
|
|
|
def exec_stream(
|
|
self,
|
|
name: str,
|
|
command: Sequence[str],
|
|
*,
|
|
workdir: str | None = None,
|
|
env: dict[str, str] | None = None,
|
|
on_stdout: Callable[[str], None] | None = None,
|
|
on_stderr: Callable[[str], None] | None = None,
|
|
) -> int:
|
|
"""Execute a command and stream SSE stdout/stderr callbacks."""
|
|
body: dict[str, Any] = {"command": list(command)}
|
|
if workdir is not None:
|
|
body["workdir"] = workdir
|
|
if env:
|
|
body["env"] = env
|
|
headers = self._auth_headers()
|
|
headers["Accept"] = "text/event-stream"
|
|
url = self._url(f"/sandboxes/{_url_component(name)}/exec/stream")
|
|
try:
|
|
with self._client.stream(
|
|
"POST",
|
|
url,
|
|
headers=headers,
|
|
json=body,
|
|
timeout=None,
|
|
) as response:
|
|
if response.status_code >= 400:
|
|
raise self._response_error("POST", url, response)
|
|
return _parse_exec_sse(
|
|
response.iter_lines(),
|
|
on_stdout=on_stdout,
|
|
on_stderr=on_stderr,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise _IsloAPIError(f"islo exec stream failed: {exc}") from exc
|
|
|
|
def _request_json(self, method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
response = self._request(method, endpoint, **kwargs)
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
raise _IsloAPIError(f"islo {method} {endpoint} returned invalid JSON") from exc
|
|
if not isinstance(data, dict):
|
|
raise _IsloAPIError(f"islo {method} {endpoint} returned a non-object response")
|
|
return data
|
|
|
|
def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
url = self._url(endpoint)
|
|
headers = kwargs.pop("headers", None) or {}
|
|
headers = {**headers, **self._auth_headers()}
|
|
try:
|
|
response = self._client.request(method, url, headers=headers, **kwargs)
|
|
except httpx.HTTPError as exc:
|
|
raise _IsloAPIError(f"islo {method} {endpoint} failed: {exc}") from exc
|
|
if response.status_code >= 400:
|
|
raise self._response_error(method, endpoint, response)
|
|
return response
|
|
|
|
def _auth_headers(self) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {self._session_token()}"}
|
|
|
|
def _session_token(self) -> str:
|
|
now = time.time()
|
|
if self._token is not None and now < self._token_expires_at:
|
|
return self._token
|
|
try:
|
|
response = self._client.post(
|
|
self._url("/auth/token"),
|
|
json={"access_key": self._api_key},
|
|
timeout=_REQUEST_TIMEOUT_S,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise _IsloAPIError(f"islo token exchange failed: {exc}") from exc
|
|
if response.status_code >= 400:
|
|
raise self._response_error("POST", "/auth/token", response)
|
|
try:
|
|
data = response.json()
|
|
except ValueError as exc:
|
|
raise _IsloAPIError("islo token exchange returned invalid JSON") from exc
|
|
token = data.get("session_token") if isinstance(data, dict) else None
|
|
if not isinstance(token, str) or not token:
|
|
raise _IsloAPIError("islo token exchange response missing session_token")
|
|
max_age = data.get("cookie_max_age", 0) if isinstance(data, dict) else 0
|
|
ttl = (
|
|
max(float(max_age) - _TOKEN_REFRESH_MARGIN_S, 0.0)
|
|
if isinstance(max_age, (int, float))
|
|
else 0.0
|
|
)
|
|
self._token = token
|
|
self._token_expires_at = now + ttl
|
|
return token
|
|
|
|
def _url(self, endpoint: str) -> str:
|
|
return self._base_url + endpoint
|
|
|
|
def _response_error(
|
|
self, method: str, endpoint: str, response: httpx.Response
|
|
) -> _IsloAPIError:
|
|
try:
|
|
text = response.text
|
|
except httpx.ResponseNotRead:
|
|
text = response.read().decode("utf-8", errors="replace")
|
|
snippet = text.strip()[:1024]
|
|
detail = f": {snippet}" if snippet else ""
|
|
return _IsloAPIError(
|
|
f"islo {method} {endpoint} failed with HTTP {response.status_code}{detail}"
|
|
)
|
|
|
|
|
|
class _IsloRemoteProcess(RemoteProcess):
|
|
"""Thread-backed :class:`RemoteProcess` over Islo exec streaming."""
|
|
|
|
def __init__(self, client: _IsloClient, sandbox_id: str, command: str) -> None:
|
|
self._client = client
|
|
self._sandbox_id = sandbox_id
|
|
self._command = command
|
|
self._lines: queue.Queue[str | None] = queue.Queue()
|
|
self._returncode: int | None = None
|
|
self._error: BaseException | None = None
|
|
self._thread = threading.Thread(target=self._run, name="islo-remote-process", daemon=True)
|
|
self._thread.start()
|
|
|
|
@property
|
|
def lines(self) -> Iterator[str]:
|
|
"""Iterator over combined stdout/stderr lines."""
|
|
while True:
|
|
item = self._lines.get()
|
|
if item is None:
|
|
return
|
|
yield item
|
|
|
|
def wait(self) -> int:
|
|
"""Block until the remote exec finishes and return its exit code."""
|
|
self._thread.join()
|
|
if self._error is not None:
|
|
raise click.ClickException(str(self._error)) from self._error
|
|
return self._returncode if self._returncode is not None else 1
|
|
|
|
def close(self) -> None:
|
|
"""Best-effort cleanup; Islo exec streams do not expose a kill handle."""
|
|
return
|
|
|
|
def _run(self) -> None:
|
|
try:
|
|
self._returncode = self._client.exec_stream(
|
|
self._sandbox_id,
|
|
["bash", "-lc", self._command],
|
|
on_stdout=self._enqueue,
|
|
on_stderr=self._enqueue,
|
|
)
|
|
except BaseException as exc:
|
|
self._error = exc
|
|
finally:
|
|
self._lines.put(None)
|
|
|
|
def _enqueue(self, text: str) -> None:
|
|
for line in text.splitlines(keepends=True):
|
|
self._lines.put(line)
|
|
if text and not text.endswith(("\n", "\r")):
|
|
self._lines.put("\n")
|
|
|
|
|
|
class IsloSandboxLauncher(SandboxLauncher):
|
|
"""
|
|
:class:`SandboxLauncher` for Islo sandboxes.
|
|
|
|
All primitives use Islo's HTTP API: sandbox create/delete for
|
|
lifecycle, exec streaming for commands, and file upload for wheel
|
|
shipping.
|
|
"""
|
|
|
|
provider: ClassVar[str] = "islo"
|
|
supports_local_port_forward: ClassVar[bool] = False
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
image: str | None = None,
|
|
env: Sequence[str] | None = None,
|
|
base_url: str | None = None,
|
|
gateway_profile: str | None = None,
|
|
snapshot_name: str | None = None,
|
|
workdir: str | None = None,
|
|
vcpus: int | None = None,
|
|
memory_mb: int | None = None,
|
|
disk_gb: int | None = None,
|
|
) -> None:
|
|
self._image_ref = image
|
|
self._env_names = tuple(env) if env is not None else None
|
|
self._base_url = base_url
|
|
self._gateway_profile = gateway_profile
|
|
self._snapshot_name = snapshot_name
|
|
self._workdir = workdir
|
|
self._vcpus = vcpus
|
|
self._memory_mb = memory_mb
|
|
self._disk_gb = disk_gb
|
|
self._client: _IsloClient | None = None
|
|
|
|
def prepare(self) -> None:
|
|
"""Verify Islo credentials are available."""
|
|
if not os.environ.get(API_KEY_ENV_VAR):
|
|
raise click.ClickException(
|
|
"No Islo credentials found. Create an API key at "
|
|
"https://islo.dev and set ISLO_API_KEY."
|
|
)
|
|
|
|
def provision(self, name: str) -> str:
|
|
"""Create a new Islo sandbox from the host image."""
|
|
resolved_ref = self._image_ref or os.environ.get(HOST_IMAGE_ENV_VAR) or DEFAULT_HOST_IMAGE
|
|
sandbox_name = _new_sandbox_name(name)
|
|
payload: dict[str, Any] = {
|
|
"name": sandbox_name,
|
|
"image": resolved_ref,
|
|
"vcpus": self._vcpus or _SANDBOX_CPU,
|
|
"memory_mb": self._memory_mb or _SANDBOX_MEMORY_MB,
|
|
"init": {"type": "minimal"},
|
|
}
|
|
env_vars = self._resolve_sandbox_env()
|
|
if env_vars:
|
|
payload["env"] = env_vars
|
|
if self._workdir:
|
|
payload["workdir"] = self._workdir
|
|
if self._gateway_profile:
|
|
payload["gateway_profile"] = self._gateway_profile
|
|
if self._snapshot_name:
|
|
payload["snapshot_name"] = self._snapshot_name
|
|
if self._disk_gb is not None:
|
|
payload["disk_gb"] = self._disk_gb
|
|
click.echo(f"▸ Creating Islo sandbox '{sandbox_name}' from {resolved_ref}")
|
|
try:
|
|
sandbox = self._islo().create_sandbox(payload)
|
|
except _IsloAPIError as exc:
|
|
raise click.ClickException(f"Islo sandbox creation failed: {exc}") from exc
|
|
created_name = sandbox.get("name")
|
|
if not isinstance(created_name, str) or not created_name:
|
|
raise click.ClickException("Islo sandbox creation returned no sandbox name")
|
|
click.echo(f" → created {created_name}")
|
|
self._clear_seeded_api_key_helper(created_name, env_vars)
|
|
return created_name
|
|
|
|
def _clear_seeded_api_key_helper(self, sandbox_id: str, env_vars: dict[str, str]) -> None:
|
|
"""
|
|
Strip Islo's gateway ``apiKeyHelper`` when the user injected their
|
|
own Claude credential.
|
|
|
|
Islo pre-seeds ``~/.claude/settings.json`` with an ``apiKeyHelper``
|
|
that resolves, through Islo's gateway, to a connected provider
|
|
integration. Claude Code prefers that helper over a
|
|
``CLAUDE_CODE_OAUTH_TOKEN`` / ``ANTHROPIC_API_KEY`` in the
|
|
environment, so a user who brings their own credential through
|
|
sandbox env passthrough would be silently overridden. When such a
|
|
credential is among the injected vars, remove the seeded helper so
|
|
the user's credential is the sole auth path. Best effort: a sandbox
|
|
with no seeded settings file is left untouched, and a failed strip
|
|
warns rather than aborting the launch.
|
|
"""
|
|
if not any(name in env_vars for name in _USER_CLAUDE_CRED_ENV_VARS):
|
|
return
|
|
click.echo(
|
|
" → clearing Islo's seeded apiKeyHelper so your injected "
|
|
"Claude credential takes precedence"
|
|
)
|
|
try:
|
|
self.run(
|
|
sandbox_id,
|
|
f"python3 -c {shlex.quote(_CLEAR_API_KEY_HELPER_SCRIPT)}",
|
|
check=False,
|
|
)
|
|
except click.ClickException as exc:
|
|
click.echo(f" → warning: could not clear seeded apiKeyHelper: {exc}", err=True)
|
|
|
|
def attach(self, sandbox_id: str) -> None:
|
|
"""Validate access to an existing Islo sandbox."""
|
|
click.echo(f"▸ Reusing existing Islo sandbox '{sandbox_id}'")
|
|
try:
|
|
self._islo().get_sandbox(sandbox_id)
|
|
except _IsloAPIError as exc:
|
|
raise click.ClickException(
|
|
f"Could not attach to Islo sandbox '{sandbox_id}': {exc}"
|
|
) from exc
|
|
|
|
def keep_alive(self, sandbox_id: str) -> None:
|
|
"""No local keep-alive setting is exposed by the Islo API."""
|
|
click.echo(f" → Islo sandbox '{sandbox_id}' remains active until deleted")
|
|
|
|
def run(self, sandbox_id: str, command: str, *, check: bool = True) -> RemoteCommandResult:
|
|
"""Run a shell command in the sandbox and capture its output."""
|
|
stdout_chunks: list[str] = []
|
|
stderr_chunks: list[str] = []
|
|
|
|
def _stdout(text: str) -> None:
|
|
stdout_chunks.append(text)
|
|
if text:
|
|
click.echo(text, nl=False)
|
|
|
|
def _stderr(text: str) -> None:
|
|
stderr_chunks.append(text)
|
|
if text:
|
|
click.echo(text, nl=False, err=True)
|
|
|
|
try:
|
|
returncode = self._islo().exec_stream(
|
|
sandbox_id,
|
|
["bash", "-lc", command],
|
|
on_stdout=_stdout,
|
|
on_stderr=_stderr,
|
|
)
|
|
except _IsloAPIError as exc:
|
|
raise click.ClickException(
|
|
f"Remote command failed to execute on Islo sandbox '{sandbox_id}': {exc}"
|
|
) from exc
|
|
stdout = "".join(stdout_chunks)
|
|
stderr = "".join(stderr_chunks)
|
|
if check and returncode != 0:
|
|
raise click.ClickException(
|
|
f"Remote command failed on Islo sandbox '{sandbox_id}' "
|
|
f"(exit {returncode}): {command}"
|
|
)
|
|
return RemoteCommandResult(returncode=returncode, stdout=stdout, stderr=stderr)
|
|
|
|
def put(self, sandbox_id: str, local_path: Path, remote_path: str) -> None:
|
|
"""Copy a local file into the sandbox."""
|
|
try:
|
|
self._islo().upload_file(sandbox_id, local_path, remote_path)
|
|
except _IsloAPIError as exc:
|
|
raise click.ClickException(
|
|
f"File upload to Islo sandbox '{sandbox_id}' failed: {exc}"
|
|
) from exc
|
|
|
|
def stream_exec(self, sandbox_id: str, command: str, *, pty: bool = False) -> RemoteProcess:
|
|
"""Spawn a command in the sandbox and stream combined output."""
|
|
del pty
|
|
return _IsloRemoteProcess(self._islo(), sandbox_id, command)
|
|
|
|
def exec_foreground(self, sandbox_id: str, command: str) -> int:
|
|
"""Run *command* in the sandbox, echoing output until it exits."""
|
|
process = self.stream_exec(sandbox_id, f"TERM=xterm-256color exec {command}", pty=True)
|
|
try:
|
|
for line in process.lines:
|
|
click.echo(line, nl=False)
|
|
return process.wait()
|
|
except KeyboardInterrupt:
|
|
click.echo("\n → detached; Islo exec streams do not expose a remote kill handle")
|
|
raise
|
|
|
|
def wheel_install_command(self, remote_tgz_path: str) -> str:
|
|
"""Remote command that overlays shipped wheels onto the host image."""
|
|
return host_image_wheel_install_command(remote_tgz_path)
|
|
|
|
def terminate(self, sandbox_id: str) -> None:
|
|
"""Delete a sandbox, releasing its compute."""
|
|
try:
|
|
self._islo().delete_sandbox(sandbox_id)
|
|
finally:
|
|
if self._client is not None:
|
|
self._client.close()
|
|
self._client = None
|
|
|
|
def _islo(self) -> _IsloClient:
|
|
if self._client is None:
|
|
api_key = os.environ.get(API_KEY_ENV_VAR)
|
|
if not api_key:
|
|
raise click.ClickException(
|
|
"No Islo credentials found. Create an API key at "
|
|
"https://islo.dev and set ISLO_API_KEY."
|
|
)
|
|
base_url = self._base_url or os.environ.get(API_BASE_URL_ENV_VAR) or _DEFAULT_BASE_URL
|
|
self._client = _IsloClient(base_url=base_url, api_key=api_key)
|
|
return self._client
|
|
|
|
def _resolve_sandbox_env(self) -> dict[str, str]:
|
|
if self._env_names is not None:
|
|
names: Sequence[str] = self._env_names
|
|
else:
|
|
names = [
|
|
name.strip()
|
|
for name in os.environ.get(SANDBOX_ENV_PASSTHROUGH_ENV_VAR, "").split(",")
|
|
if name.strip()
|
|
]
|
|
resolved: dict[str, str] = {}
|
|
for name in names:
|
|
value = os.environ.get(name)
|
|
if value is None:
|
|
raise click.ClickException(
|
|
f"sandbox env passthrough names '{name}' but it is not set "
|
|
"in the server's environment — set it (or remove it from "
|
|
f"sandbox.islo.env / {SANDBOX_ENV_PASSTHROUGH_ENV_VAR})."
|
|
)
|
|
resolved[name] = value
|
|
return resolved
|
|
|
|
|
|
def _url_component(value: str) -> str:
|
|
return quote(value, safe="")
|
|
|
|
|
|
def _new_sandbox_name(label: str) -> str:
|
|
base = re.sub(r"[^a-z0-9-]+", "-", label.lower()).strip("-")
|
|
base = re.sub(r"-+", "-", base) or "host"
|
|
return f"omnigent-{base[:40]}-{uuid.uuid4().hex[:6]}"
|
|
|
|
|
|
def _parse_exec_sse(
|
|
lines: Iterator[str],
|
|
*,
|
|
on_stdout: Callable[[str], None] | None,
|
|
on_stderr: Callable[[str], None] | None,
|
|
) -> int:
|
|
exit_code = 1
|
|
seen_exit = False
|
|
event = ""
|
|
data: list[str] = []
|
|
|
|
def flush() -> None:
|
|
nonlocal event, data, exit_code, seen_exit
|
|
if not event and not data:
|
|
return
|
|
payload = "\n".join(data)
|
|
if event == "stdout" and on_stdout is not None:
|
|
on_stdout(payload)
|
|
elif event == "stderr" and on_stderr is not None:
|
|
on_stderr(payload)
|
|
elif event == "exit":
|
|
try:
|
|
exit_code = int(payload.strip())
|
|
except ValueError as exc:
|
|
raise _IsloAPIError(f"islo exec stream invalid exit event {payload!r}") from exc
|
|
seen_exit = True
|
|
event = ""
|
|
data = []
|
|
|
|
for raw_line in lines:
|
|
line = raw_line.rstrip("\r")
|
|
if line == "":
|
|
flush()
|
|
continue
|
|
if line.startswith(":"):
|
|
continue
|
|
field, sep, value = line.partition(":")
|
|
if sep:
|
|
value = value.removeprefix(" ")
|
|
if field == "event":
|
|
event = value
|
|
elif field == "data":
|
|
data.append(value)
|
|
flush()
|
|
if not seen_exit:
|
|
raise _IsloAPIError("islo exec stream ended without exit event")
|
|
return exit_code
|