337 lines
11 KiB
Python
337 lines
11 KiB
Python
"""Tests for :mod:`omnigent.onboarding.sandboxes.islo`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import click
|
|
import pytest
|
|
|
|
import omnigent.onboarding.sandboxes.islo as islo_mod
|
|
from omnigent.onboarding.sandboxes.base import DEFAULT_HOST_IMAGE
|
|
from omnigent.onboarding.sandboxes.islo import (
|
|
API_KEY_ENV_VAR,
|
|
HOST_IMAGE_ENV_VAR,
|
|
SANDBOX_ENV_PASSTHROUGH_ENV_VAR,
|
|
IsloSandboxLauncher,
|
|
_IsloClient,
|
|
_parse_exec_sse,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _HttpRequest:
|
|
"""One recorded fake HTTP request."""
|
|
|
|
method: str
|
|
url: str
|
|
headers: dict[str, str]
|
|
kwargs: dict[str, Any]
|
|
|
|
|
|
class _FakeResponse:
|
|
"""Minimal ``httpx.Response`` stand-in for the Islo client tests."""
|
|
|
|
def __init__(self, status_code: int, data: dict[str, Any], text: str = "") -> None:
|
|
self.status_code = status_code
|
|
self._data = data
|
|
self.text = text
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
"""Return the canned JSON body."""
|
|
return self._data
|
|
|
|
|
|
class _FakeHTTPClient:
|
|
"""Recorder for the subset of ``httpx.Client`` used by ``_IsloClient``."""
|
|
|
|
def __init__(self) -> None:
|
|
self.token_posts: list[dict[str, Any]] = []
|
|
self.requests: list[_HttpRequest] = []
|
|
self.closed = False
|
|
|
|
def post(self, url: str, *, json: dict[str, Any], timeout: float) -> _FakeResponse:
|
|
"""Record the token exchange and return a cacheable session token."""
|
|
self.token_posts.append({"url": url, "json": json, "timeout": timeout})
|
|
return _FakeResponse(200, {"session_token": "session-123", "cookie_max_age": 120})
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str],
|
|
**kwargs: Any,
|
|
) -> _FakeResponse:
|
|
"""Record an authenticated API request and return an object body."""
|
|
self.requests.append(_HttpRequest(method=method, url=url, headers=headers, kwargs=kwargs))
|
|
return _FakeResponse(200, {"ok": True})
|
|
|
|
def close(self) -> None:
|
|
"""Record close."""
|
|
self.closed = True
|
|
|
|
|
|
@dataclass
|
|
class _ExecCall:
|
|
"""One fake Islo exec-stream invocation."""
|
|
|
|
sandbox_id: str
|
|
command: list[str]
|
|
|
|
|
|
@dataclass
|
|
class _FakeIsloAPI:
|
|
"""Recorder for the launcher-facing Islo API client."""
|
|
|
|
create_payloads: list[dict[str, Any]] = field(default_factory=list)
|
|
exec_calls: list[_ExecCall] = field(default_factory=list)
|
|
uploads: list[tuple[str, Path, str]] = field(default_factory=list)
|
|
deleted: list[str] = field(default_factory=list)
|
|
|
|
def create_sandbox(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Record a create request and echo the sandbox name back."""
|
|
self.create_payloads.append(dict(payload))
|
|
return {"name": payload["name"]}
|
|
|
|
def get_sandbox(self, name: str) -> dict[str, Any]:
|
|
"""Return a canned sandbox object."""
|
|
return {"name": name}
|
|
|
|
def delete_sandbox(self, name: str) -> None:
|
|
"""Record deletion."""
|
|
self.deleted.append(name)
|
|
|
|
def upload_file(self, name: str, local_path: Path, remote_path: str) -> None:
|
|
"""Record file upload."""
|
|
self.uploads.append((name, local_path, remote_path))
|
|
|
|
def exec_stream(
|
|
self,
|
|
name: str,
|
|
command: list[str],
|
|
*,
|
|
workdir: str | None = None,
|
|
env: dict[str, str] | None = None,
|
|
on_stdout: Any = None,
|
|
on_stderr: Any = None,
|
|
) -> int:
|
|
"""Record command execution and emit one chunk per stream."""
|
|
del workdir, env
|
|
self.exec_calls.append(_ExecCall(sandbox_id=name, command=command))
|
|
if on_stdout is not None:
|
|
on_stdout("out\n")
|
|
if on_stderr is not None:
|
|
on_stderr("err\n")
|
|
return 0
|
|
|
|
|
|
def test_client_exchanges_access_key_for_cached_bearer_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``ISLO_API_KEY`` is not sent to sandbox endpoints directly; it is
|
|
exchanged for a session token that authenticates API calls.
|
|
"""
|
|
fake = _FakeHTTPClient()
|
|
monkeypatch.setattr(islo_mod.httpx, "Client", lambda **kwargs: fake)
|
|
|
|
client = _IsloClient(base_url="https://api.islo.dev/", api_key="ak-test")
|
|
|
|
assert client.create_sandbox({"name": "sb-1"}) == {"ok": True}
|
|
assert client.get_sandbox("sb/1") == {"ok": True}
|
|
|
|
assert fake.token_posts == [
|
|
{
|
|
"url": "https://api.islo.dev/auth/token",
|
|
"json": {"access_key": "ak-test"},
|
|
"timeout": 30.0,
|
|
}
|
|
]
|
|
assert [req.headers["Authorization"] for req in fake.requests] == [
|
|
"Bearer session-123",
|
|
"Bearer session-123",
|
|
]
|
|
assert fake.requests[1].url == "https://api.islo.dev/sandboxes/sb%2F1"
|
|
|
|
|
|
def test_prepare_requires_islo_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Preflight fails before provisioning when ``ISLO_API_KEY`` is absent."""
|
|
monkeypatch.delenv(API_KEY_ENV_VAR, raising=False)
|
|
with pytest.raises(click.ClickException, match="ISLO_API_KEY"):
|
|
IsloSandboxLauncher().prepare()
|
|
|
|
monkeypatch.setenv(API_KEY_ENV_VAR, "ak-test")
|
|
IsloSandboxLauncher().prepare()
|
|
|
|
|
|
def test_provision_builds_islo_create_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
Provisioning sends the official host image defaults plus configured
|
|
env passthrough and Islo-specific resource/profile fields.
|
|
"""
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
monkeypatch.setenv("GIT_TOKEN", "ghp-test")
|
|
monkeypatch.setattr(islo_mod, "_new_sandbox_name", lambda label: "omnigent-fixed")
|
|
fake = _FakeIsloAPI()
|
|
launcher = IsloSandboxLauncher(
|
|
image="docker.io/me/omnigent-host:latest",
|
|
env=["OPENAI_API_KEY", "GIT_TOKEN"],
|
|
gateway_profile="default",
|
|
snapshot_name="warm-host",
|
|
workdir="/root/workspace",
|
|
vcpus=4,
|
|
memory_mb=8192,
|
|
disk_gb=40,
|
|
)
|
|
monkeypatch.setattr(launcher, "_islo", lambda: fake)
|
|
|
|
sandbox_id = launcher.provision("Managed Host")
|
|
|
|
assert sandbox_id == "omnigent-fixed"
|
|
assert fake.create_payloads == [
|
|
{
|
|
"name": "omnigent-fixed",
|
|
"image": "docker.io/me/omnigent-host:latest",
|
|
"vcpus": 4,
|
|
"memory_mb": 8192,
|
|
"init": {"type": "minimal"},
|
|
"env": {"OPENAI_API_KEY": "sk-test", "GIT_TOKEN": "ghp-test"},
|
|
"workdir": "/root/workspace",
|
|
"gateway_profile": "default",
|
|
"snapshot_name": "warm-host",
|
|
"disk_gb": 40,
|
|
}
|
|
]
|
|
|
|
|
|
def test_provision_uses_image_and_env_var_fallbacks(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
Without constructor fields, host image and sandbox env names resolve
|
|
from process env vars; otherwise the official image and empty env apply.
|
|
"""
|
|
monkeypatch.setenv(HOST_IMAGE_ENV_VAR, "docker.io/env/host:1")
|
|
monkeypatch.setenv(SANDBOX_ENV_PASSTHROUGH_ENV_VAR, "OPENAI_API_KEY")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
monkeypatch.setattr(islo_mod, "_new_sandbox_name", lambda label: "omnigent-env")
|
|
fake = _FakeIsloAPI()
|
|
launcher = IsloSandboxLauncher()
|
|
monkeypatch.setattr(launcher, "_islo", lambda: fake)
|
|
|
|
launcher.provision("a")
|
|
|
|
[payload] = fake.create_payloads
|
|
assert payload["image"] == "docker.io/env/host:1"
|
|
assert payload["env"] == {"OPENAI_API_KEY": "sk-test"}
|
|
|
|
monkeypatch.delenv(HOST_IMAGE_ENV_VAR)
|
|
monkeypatch.delenv(SANDBOX_ENV_PASSTHROUGH_ENV_VAR)
|
|
fake2 = _FakeIsloAPI()
|
|
launcher2 = IsloSandboxLauncher()
|
|
monkeypatch.setattr(launcher2, "_islo", lambda: fake2)
|
|
|
|
launcher2.provision("b")
|
|
|
|
[payload2] = fake2.create_payloads
|
|
assert payload2["image"] == DEFAULT_HOST_IMAGE
|
|
assert "env" not in payload2
|
|
|
|
|
|
def test_provision_env_passthrough_missing_var_fails_loud(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A configured but unset env name aborts before creating a sandbox."""
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
fake = _FakeIsloAPI()
|
|
launcher = IsloSandboxLauncher(env=["OPENAI_API_KEY"])
|
|
monkeypatch.setattr(launcher, "_islo", lambda: fake)
|
|
|
|
with pytest.raises(click.ClickException, match="OPENAI_API_KEY"):
|
|
launcher.provision("a")
|
|
assert fake.create_payloads == []
|
|
|
|
|
|
@pytest.mark.parametrize("cred_var", ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"])
|
|
def test_provision_clears_seeded_helper_when_user_injects_claude_cred(
|
|
monkeypatch: pytest.MonkeyPatch, cred_var: str
|
|
) -> None:
|
|
"""
|
|
A user-injected Claude credential strips Islo's gateway ``apiKeyHelper``
|
|
so the injected credential wins (covers both CLI and managed launches,
|
|
which share ``provision``).
|
|
"""
|
|
monkeypatch.setenv(cred_var, "secret-value")
|
|
monkeypatch.setattr(islo_mod, "_new_sandbox_name", lambda label: "omnigent-byo")
|
|
fake = _FakeIsloAPI()
|
|
launcher = IsloSandboxLauncher(env=[cred_var])
|
|
monkeypatch.setattr(launcher, "_islo", lambda: fake)
|
|
|
|
launcher.provision("host")
|
|
|
|
strip_calls = [call for call in fake.exec_calls if "apiKeyHelper" in call.command[-1]]
|
|
assert len(strip_calls) == 1
|
|
assert strip_calls[0].sandbox_id == "omnigent-byo"
|
|
assert strip_calls[0].command[:2] == ["bash", "-lc"]
|
|
|
|
|
|
def test_provision_keeps_seeded_helper_without_user_claude_cred(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Gateway users (Option A) inject no Claude credential, so the seeded helper stays."""
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
monkeypatch.setattr(islo_mod, "_new_sandbox_name", lambda label: "omnigent-gw")
|
|
fake = _FakeIsloAPI()
|
|
launcher = IsloSandboxLauncher(env=["OPENAI_API_KEY"])
|
|
monkeypatch.setattr(launcher, "_islo", lambda: fake)
|
|
|
|
launcher.provision("host")
|
|
|
|
assert all("apiKeyHelper" not in call.command[-1] for call in fake.exec_calls)
|
|
|
|
|
|
def test_run_streams_stdout_and_stderr(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""``run`` calls Islo exec streaming through ``bash -lc`` and captures both streams."""
|
|
fake = _FakeIsloAPI()
|
|
launcher = IsloSandboxLauncher()
|
|
monkeypatch.setattr(launcher, "_islo", lambda: fake)
|
|
|
|
result = launcher.run("sb-1", "printf hi")
|
|
|
|
assert fake.exec_calls == [_ExecCall(sandbox_id="sb-1", command=["bash", "-lc", "printf hi"])]
|
|
assert result.returncode == 0
|
|
assert result.stdout == "out\n"
|
|
assert result.stderr == "err\n"
|
|
|
|
|
|
def test_parse_exec_sse_routes_events_and_requires_exit() -> None:
|
|
"""Islo exec SSE events are routed by event type and must include an exit code."""
|
|
stdout: list[str] = []
|
|
stderr: list[str] = []
|
|
|
|
returncode = _parse_exec_sse(
|
|
iter(
|
|
[
|
|
"event: stdout",
|
|
"data: hello",
|
|
"",
|
|
"event: stderr",
|
|
"data: warn",
|
|
"",
|
|
"event: exit",
|
|
"data: 7",
|
|
"",
|
|
]
|
|
),
|
|
on_stdout=stdout.append,
|
|
on_stderr=stderr.append,
|
|
)
|
|
|
|
assert returncode == 7
|
|
assert stdout == ["hello"]
|
|
assert stderr == ["warn"]
|
|
|
|
with pytest.raises(RuntimeError, match="without exit event"):
|
|
_parse_exec_sse(iter(["event: stdout", "data: hello", ""]), on_stdout=None, on_stderr=None)
|