chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test: create a managed session against an Omnigent server, and have
|
||||
the agent run a REAL workload — an LLM turn against the CoreWeave / W&B inference
|
||||
endpoint — from inside the managed CHILD sandbox the server provisions.
|
||||
|
||||
Two modes:
|
||||
|
||||
1. --server <url>: use an EXISTING omnigent server (already configured with
|
||||
sandbox.provider=cwsandbox). No public-IP sandbox required — good for W&B
|
||||
serverless users who can't expose a public service.
|
||||
|
||||
python tests/e2e/integrations/deploy/cwsandbox/e2e_managed.py \
|
||||
--server http://my-omnigent:6767
|
||||
|
||||
2. (default) spin the server up inside a CW Sandbox with a public service. The
|
||||
sandbox runs a prebaked image with this fork's omnigent + the cwsandbox SDK
|
||||
(build/push first; pass --image), and the driver injects the LLM creds.
|
||||
|
||||
export CWSANDBOX_API_KEY=... # provisions the sandboxes
|
||||
export WANDB_INFERENCE_KEY=... # the agent's LLM credential
|
||||
python tests/e2e/integrations/deploy/cwsandbox/e2e_managed.py \
|
||||
--image docker.io/<you>/omnigent-cwsandbox:test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from cwsandbox import NetworkOptions, Sandbox
|
||||
|
||||
SERVER_PORT = 6767
|
||||
CONFIG_HOME = "/root/.omnigent"
|
||||
WANDB_BASE_URL = "https://api.inference.wandb.ai/v1"
|
||||
WANDB_MODEL = "Qwen/Qwen3-Coder-480B-A35B-Instruct"
|
||||
PROMPT = "What is 2+2? Reply with ONLY the number, nothing else."
|
||||
|
||||
|
||||
def _child_env(wandb_key: str) -> dict[str, str]:
|
||||
"""Env injected into every managed CHILD sandbox — the single source of truth.
|
||||
|
||||
The launcher forwards these by NAME from the server process env. OPENAI_*
|
||||
reach the harness automatically; the HARNESS_* knobs ride
|
||||
OMNIGENT_RUNNER_ENV_PASSTHROUGH. The config's `sandbox.cwsandbox.env` name
|
||||
list and the server sandbox's env values both derive from this dict.
|
||||
"""
|
||||
return {
|
||||
"OPENAI_API_KEY": wandb_key,
|
||||
"OPENAI_BASE_URL": WANDB_BASE_URL,
|
||||
"HARNESS_OPENAI_AGENTS_MODEL": WANDB_MODEL,
|
||||
# W&B is chat/completions-compatible, not the Responses API.
|
||||
"HARNESS_OPENAI_AGENTS_USE_RESPONSES": "0",
|
||||
# Tell the in-child host to forward the HARNESS_* knobs to the runner.
|
||||
"OMNIGENT_RUNNER_ENV_PASSTHROUGH": (
|
||||
"HARNESS_OPENAI_AGENTS_MODEL,HARNESS_OPENAI_AGENTS_USE_RESPONSES"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def start_server_sandbox(image: str, cw_key: str, wandb_key: str) -> tuple[Sandbox, str]:
|
||||
"""Provision the server sandbox (public service) carrying the child-env values."""
|
||||
log(f"[1/6] provisioning server sandbox from {image}")
|
||||
sb = Sandbox.run(
|
||||
"sleep",
|
||||
"infinity",
|
||||
container_image=image,
|
||||
profile_names=["default"],
|
||||
max_lifetime_seconds=3600,
|
||||
resources={"cpu": "2", "memory": "4Gi"},
|
||||
network=NetworkOptions(
|
||||
ingress_mode="public", exposed_ports=[SERVER_PORT], egress_mode="internet"
|
||||
),
|
||||
environment_variables={
|
||||
"CWSANDBOX_API_KEY": cw_key,
|
||||
"OMNIGENT_CWSANDBOX_HOST_IMAGE": image,
|
||||
# Values the launcher passes through (by name) into each child:
|
||||
**_child_env(wandb_key),
|
||||
},
|
||||
tags=["omnigent-e2e", "server"],
|
||||
)
|
||||
sb.wait()
|
||||
ip = (sb.service_address or "").split(":")[0]
|
||||
if not ip:
|
||||
raise SystemExit(f"server sandbox {sb.sandbox_id} has no public service address")
|
||||
log(f" sandbox={sb.sandbox_id} public_ip={ip}")
|
||||
return sb, ip
|
||||
|
||||
|
||||
def _write(sb: Sandbox, path: str, content: str) -> None:
|
||||
b64 = base64.b64encode(content.encode()).decode()
|
||||
sb.exec(
|
||||
["bash", "-lc", f"mkdir -p $(dirname {path}) && echo {b64} | base64 -d > {path}"]
|
||||
).result()
|
||||
|
||||
|
||||
def configure_and_start_server(sb: Sandbox, server_url: str, wandb_key: str) -> None:
|
||||
"""Write config + an openai-agents agent, then launch `omnigent server`."""
|
||||
log(f"[2/6] starting omnigent server (server_url={server_url})")
|
||||
child_env_names = ", ".join(_child_env(wandb_key))
|
||||
_write(
|
||||
sb,
|
||||
f"{CONFIG_HOME}/config.yaml",
|
||||
"sandbox:\n"
|
||||
" provider: cwsandbox\n"
|
||||
f" server_url: {server_url}\n"
|
||||
" cwsandbox:\n"
|
||||
f" env: [{child_env_names}]\n",
|
||||
)
|
||||
# Agent bound to the openai-agents harness + the W&B model. executor.auth
|
||||
# (ApiKeyAuth) is what the runner's gateway routing reads for base_url +
|
||||
# api_key — the bare OPENAI_BASE_URL env is ignored, so this is required
|
||||
# to target W&B instead of defaulting to api.openai.com.
|
||||
_write(
|
||||
sb,
|
||||
"/root/e2e-agent/agent.yaml",
|
||||
"name: e2e-probe\n"
|
||||
"prompt: You are a terse calculator. Answer with only the number.\n"
|
||||
"executor:\n"
|
||||
" harness: openai-agents\n"
|
||||
f" model: {WANDB_MODEL}\n"
|
||||
" auth:\n"
|
||||
" type: api_key\n"
|
||||
f" api_key: {wandb_key}\n"
|
||||
f" base_url: {WANDB_BASE_URL}\n",
|
||||
)
|
||||
start = (
|
||||
f"OMNIGENT_CONFIG_HOME={CONFIG_HOME} OMNIGENT_LOCAL_SINGLE_USER=1 "
|
||||
f"setsid nohup omnigent server --host 0.0.0.0 --port {SERVER_PORT} "
|
||||
f"--config {CONFIG_HOME}/config.yaml --no-open --agent /root/e2e-agent "
|
||||
"> /tmp/omnigent-server.log 2>&1 < /dev/null & echo started"
|
||||
)
|
||||
sb.exec(["bash", "-lc", start]).result()
|
||||
|
||||
|
||||
def wait_server_ready(base: str, sb: Sandbox | None, timeout_s: float = 120.0) -> dict:
|
||||
log(f"[3/6] waiting for {base}/v1/info")
|
||||
deadline = time.monotonic() + timeout_s
|
||||
last = ""
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
r = httpx.get(f"{base}/v1/info", timeout=5.0)
|
||||
if r.status_code == 200:
|
||||
log(f" ready: {json.dumps(r.json())}")
|
||||
return r.json()
|
||||
last = f"HTTP {r.status_code}"
|
||||
except httpx.HTTPError as exc:
|
||||
last = str(exc)
|
||||
time.sleep(3.0)
|
||||
log(f" not ready ({last}); server log:")
|
||||
_dump_server_logs(sb)
|
||||
raise SystemExit("server never became ready")
|
||||
|
||||
|
||||
def pick_agent(base: str, agent_id: str | None = None) -> str:
|
||||
resp = httpx.get(f"{base}/v1/agents", timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
agents = resp.json()["data"]
|
||||
if not agents:
|
||||
raise SystemExit("no agents registered on the server to bind a session to")
|
||||
if agent_id:
|
||||
chosen = next((a for a in agents if a.get("id") == agent_id), None)
|
||||
if chosen is None:
|
||||
raise SystemExit(f"agent_id {agent_id!r} not found on the server")
|
||||
else:
|
||||
chosen = next((a for a in agents if a.get("name") == "e2e-probe"), agents[0])
|
||||
log(f" agent_id={chosen['id']} ({chosen.get('name')})")
|
||||
return chosen["id"]
|
||||
|
||||
|
||||
def create_managed_session(base: str, agent_id: str) -> str:
|
||||
log("[4/6] creating managed session with a prompt")
|
||||
body = {
|
||||
"agent_id": agent_id,
|
||||
"host_type": "managed",
|
||||
"initial_items": [
|
||||
{
|
||||
"type": "message",
|
||||
"data": {"role": "user", "content": [{"type": "input_text", "text": PROMPT}]},
|
||||
}
|
||||
],
|
||||
}
|
||||
r = httpx.post(f"{base}/v1/sessions", json=body, timeout=180.0)
|
||||
if r.status_code >= 300:
|
||||
raise SystemExit(f"create session failed: HTTP {r.status_code}: {r.text[:600]}")
|
||||
conv_id = r.json()["id"]
|
||||
log(f" session={conv_id}")
|
||||
return conv_id
|
||||
|
||||
|
||||
def _dump_server_logs(sb: Sandbox | None) -> None:
|
||||
if sb is None:
|
||||
log(" (external server — check its own logs)")
|
||||
return
|
||||
out = sb.exec(
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
"tail -50 ~/.omnigent/logs/cli-*.log 2>/dev/null; "
|
||||
"echo '--- stdout ---'; tail -15 /tmp/omnigent-server.log",
|
||||
]
|
||||
).result()
|
||||
log(out.stdout)
|
||||
|
||||
|
||||
def _omnigent_children() -> list:
|
||||
"""List sandboxes the launcher tags 'omnigent' (managed hosts); [] on error."""
|
||||
try:
|
||||
return Sandbox.list(tags=["omnigent"]).result()
|
||||
except Exception as exc:
|
||||
log(f" (could not list child sandboxes: {exc})")
|
||||
return []
|
||||
|
||||
|
||||
def dump_child_logs(exclude: set[str]) -> None:
|
||||
"""Dump runner/harness logs from a CHILD this run created (not in *exclude*)."""
|
||||
# Scope to children NOT present before this run — never touch sandboxes
|
||||
# belonging to other runs / deployments that share the 'omnigent' tag.
|
||||
children = [c for c in _omnigent_children() if c.sandbox_id not in exclude]
|
||||
running = [c for c in children if "RUNNING" in str(getattr(c, "status", "")).upper()]
|
||||
target = (running or children or [None])[0]
|
||||
if target is None:
|
||||
log(" (no child sandbox found for this run)")
|
||||
return
|
||||
log(f" --- child sandbox {target.sandbox_id} runner/harness logs ---")
|
||||
try:
|
||||
out = target.exec(
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
"tail -60 ~/.omnigent/logs/*.log 2>/dev/null; echo '--- host log ---'; "
|
||||
"tail -40 /tmp/omnigent-host.log 2>/dev/null",
|
||||
]
|
||||
).result()
|
||||
log(out.stdout)
|
||||
except Exception as exc:
|
||||
log(f" (could not read child logs: {exc})")
|
||||
|
||||
|
||||
def wait_host_online(
|
||||
base: str, conv_id: str, sb: Sandbox | None, timeout_s: float = 360.0
|
||||
) -> bool:
|
||||
log("[5/6] waiting for the managed host to register (child sandbox)")
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
d = httpx.get(f"{base}/v1/sessions/{conv_id}", timeout=5.0).json()
|
||||
if d.get("host_online"):
|
||||
log(f" ✓ host online: host_id={d.get('host_id')}")
|
||||
log(f" runner_id={d.get('runner_id')}")
|
||||
return True
|
||||
if d.get("last_task_error"):
|
||||
log(f" launch error: {d['last_task_error']}")
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(5.0)
|
||||
log(" host did not come online; server logs:")
|
||||
_dump_server_logs(sb)
|
||||
return False
|
||||
|
||||
|
||||
def _assistant_text(items: list[dict]) -> str:
|
||||
"""Extract concatenated assistant text from session items."""
|
||||
out = []
|
||||
for it in items:
|
||||
if it.get("type") != "message":
|
||||
continue
|
||||
data = it.get("data") or {}
|
||||
if data.get("role") != "assistant":
|
||||
continue
|
||||
for block in data.get("content") or []:
|
||||
if isinstance(block, dict) and block.get("text"):
|
||||
out.append(block["text"])
|
||||
elif isinstance(block, str):
|
||||
out.append(block)
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def wait_for_reply(
|
||||
base: str,
|
||||
conv_id: str,
|
||||
sb: Sandbox | None,
|
||||
pre_children: set[str],
|
||||
timeout_s: float = 180.0,
|
||||
) -> str | None:
|
||||
"""Poll session items until the agent posts an assistant reply."""
|
||||
log("[6/6] waiting for the agent to run the LLM turn and reply")
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
d = httpx.get(f"{base}/v1/sessions/{conv_id}", timeout=5.0).json()
|
||||
# Check for a failure FIRST — an error that arrives after partial
|
||||
# assistant text must not be masked by returning that text.
|
||||
if d.get("last_task_error"):
|
||||
log(f" task error: {d['last_task_error']}")
|
||||
break
|
||||
# Only accept the reply once the turn has FINISHED (status back to
|
||||
# idle), so an intermediate/streaming block isn't a false PASS.
|
||||
text = _assistant_text(d.get("items") or [])
|
||||
if text and d.get("status") == "idle":
|
||||
return text
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(4.0)
|
||||
log(" no reply.")
|
||||
try:
|
||||
items = httpx.get(f"{base}/v1/sessions/{conv_id}", timeout=5.0).json().get("items", [])
|
||||
log(f" raw items: {json.dumps(items)[:1500]}")
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
log(" server logs:")
|
||||
_dump_server_logs(sb)
|
||||
if sb is not None:
|
||||
log(" child sandbox logs:")
|
||||
dump_child_logs(pre_children)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--server",
|
||||
default=None,
|
||||
help="Use an EXISTING omnigent server at this URL (e.g. http://host:6767) "
|
||||
"instead of spinning one up in a CW sandbox. The server must already be "
|
||||
"configured with sandbox.provider=cwsandbox. No public-IP sandbox needed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--image",
|
||||
default=None,
|
||||
help="Prebaked omnigent+cwsandbox image (required only when spinning up the server).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--agent-id",
|
||||
default=None,
|
||||
help="Bind the session to this agent id (default: the seeded e2e-probe, "
|
||||
"else the first registered agent). Useful with --server.",
|
||||
)
|
||||
parser.add_argument("--keep", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
external = args.server is not None
|
||||
cw_key = os.environ.get("CWSANDBOX_API_KEY")
|
||||
wandb_key = os.environ.get("WANDB_INFERENCE_KEY")
|
||||
if not external:
|
||||
# Self-hosted mode spins up the server sandbox + injects the LLM creds.
|
||||
if not args.image:
|
||||
print("ERROR: --image is required unless --server is given", file=sys.stderr)
|
||||
return 2
|
||||
if not cw_key or not wandb_key:
|
||||
print("ERROR: set CWSANDBOX_API_KEY and WANDB_INFERENCE_KEY", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
sb: Sandbox | None = None
|
||||
if external:
|
||||
base = args.server.rstrip("/")
|
||||
log(f"[*] using existing omnigent server at {base}")
|
||||
else:
|
||||
sb, ip = start_server_sandbox(args.image, cw_key, wandb_key)
|
||||
base = f"http://{ip}:{SERVER_PORT}"
|
||||
|
||||
# Children carry the shared "omnigent" tag, so snapshot which ones already
|
||||
# existed before this run — we only ever touch the ones WE cause to appear,
|
||||
# never another run's / deployment's managed hosts.
|
||||
pre_children = {c.sandbox_id for c in _omnigent_children()} if sb is not None else set()
|
||||
|
||||
reply = None
|
||||
try:
|
||||
if not external:
|
||||
configure_and_start_server(sb, base, wandb_key)
|
||||
wait_server_ready(base, sb)
|
||||
agent_id = pick_agent(base, args.agent_id)
|
||||
conv_id = create_managed_session(base, agent_id)
|
||||
if wait_host_online(base, conv_id, sb):
|
||||
reply = wait_for_reply(base, conv_id, sb, pre_children)
|
||||
finally:
|
||||
# Only tear down sandboxes WE created. An external server owns its own
|
||||
# children, so leave them alone.
|
||||
if sb is not None and not args.keep:
|
||||
log(f"cleaning up server sandbox {sb.sandbox_id} + managed child(ren)")
|
||||
try:
|
||||
sb.stop().result()
|
||||
except Exception as exc:
|
||||
log(f" warning: server cleanup failed: {exc}")
|
||||
# Stop only children that appeared during this run (id not in the
|
||||
# pre-run snapshot) — never another run's sandboxes.
|
||||
for child in _omnigent_children():
|
||||
if child.sandbox_id in pre_children:
|
||||
continue
|
||||
try:
|
||||
child.stop().result()
|
||||
log(f" stopped child {child.sandbox_id}")
|
||||
except Exception as exc:
|
||||
log(f" warning: child cleanup failed for {child.sandbox_id}: {exc}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if reply:
|
||||
print("E2E PASSED — agent ran a real LLM workload in the sandbox.")
|
||||
print(f"Prompt: {PROMPT}")
|
||||
print(f"Reply: {reply!r}")
|
||||
return 0
|
||||
print("E2E FAILED — see logs above.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Smoke test for the CoreWeave Sandbox (cwsandbox) provider.
|
||||
|
||||
Talks directly to the CW Sandbox HTTP API to validate the
|
||||
primitives the managed-host launcher relies on: provision → RUNNING,
|
||||
unary exec, AddFile, public egress, detach survival (setsid nohup), and
|
||||
terminate.
|
||||
|
||||
export CWSANDBOX_API_KEY=...
|
||||
python tests/e2e/integrations/deploy/cwsandbox/smoke_test.py [--image IMG] [--keep]
|
||||
|
||||
Transport is curl (subprocess): the system Python's TLS may be too old
|
||||
to reach the API, while curl uses the OS's. Zero pip dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
DEFAULT_IMAGE = "python:3.11-slim"
|
||||
MAX_LIFETIME_S = 3600
|
||||
PROVISION_TIMEOUT_S = 300
|
||||
POLL_INTERVAL_S = 3.0
|
||||
|
||||
|
||||
class SmokeError(Exception):
|
||||
"""A smoke-test check failed."""
|
||||
|
||||
|
||||
class Client:
|
||||
"""CW Sandbox API client over curl (Bearer auth, JSON)."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str) -> None:
|
||||
self._base = base_url.rstrip("/")
|
||||
self._auth = f"Authorization: Bearer {api_key}"
|
||||
|
||||
def _request(self, method: str, path: str, op: str, body: dict | None = None) -> dict:
|
||||
cmd = [
|
||||
"curl",
|
||||
"-sS",
|
||||
"-X",
|
||||
method,
|
||||
f"{self._base}{path}",
|
||||
"-H",
|
||||
self._auth,
|
||||
"-H",
|
||||
"Accept: application/json",
|
||||
"-w",
|
||||
"\n%{http_code}",
|
||||
]
|
||||
if body is not None:
|
||||
cmd += ["-H", "Content-Type: application/json", "--data-binary", json.dumps(body)]
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=70.0)
|
||||
except FileNotFoundError as exc:
|
||||
raise SmokeError("curl not found on PATH") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise SmokeError(f"{op} -> timed out") from exc
|
||||
if proc.returncode != 0:
|
||||
raise SmokeError(f"{op} -> curl failed: {proc.stderr.strip()[:300]}")
|
||||
raw, _, status = proc.stdout.rpartition("\n")
|
||||
if not status.isdigit() or int(status) >= 400:
|
||||
raise SmokeError(f"{op} -> HTTP {status}: {raw[:500]}")
|
||||
return json.loads(raw) if raw.strip() else {}
|
||||
|
||||
def provision(self, *, image: str, name: str) -> str:
|
||||
body = {
|
||||
"containerImage": image,
|
||||
"command": "sleep",
|
||||
"args": ["infinity"],
|
||||
"resources": {"cpu": "1", "memory": "1Gi"},
|
||||
"maxLifetimeSeconds": MAX_LIFETIME_S,
|
||||
"network": {"egressMode": "internet"}, # egress defaults to none
|
||||
"tags": ["omnigent-smoke", name],
|
||||
}
|
||||
return self._request("POST", "/v1beta2/sandboxes", "provision", body)["sandboxId"]
|
||||
|
||||
def wait_running(self, sandbox_id: str) -> None:
|
||||
deadline = time.monotonic() + PROVISION_TIMEOUT_S
|
||||
last = ""
|
||||
while time.monotonic() < deadline:
|
||||
payload = self._request("GET", f"/v1beta2/sandboxes/{sandbox_id}", "get")
|
||||
status = payload.get("sandboxStatus", "")
|
||||
if status != last:
|
||||
print(f" status: {status}")
|
||||
last = status
|
||||
if status == "SANDBOX_STATUS_RUNNING":
|
||||
return
|
||||
if status.startswith("SANDBOX_STATUS_") and status not in (
|
||||
"SANDBOX_STATUS_CREATING",
|
||||
"SANDBOX_STATUS_PENDING",
|
||||
"SANDBOX_STATUS_UNSPECIFIED",
|
||||
):
|
||||
raise SmokeError(f"terminal status {status}: {payload.get('statusReason', '?')}")
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
raise SmokeError(f"not RUNNING within {PROVISION_TIMEOUT_S}s (last={last})")
|
||||
|
||||
def exec(self, sandbox_id: str, command: str) -> tuple[int, str, str]:
|
||||
result = self._request(
|
||||
"POST",
|
||||
f"/v1beta2/sandboxes/{sandbox_id}/exec",
|
||||
"exec",
|
||||
{"command": ["bash", "-lc", command], "maxTimeoutSeconds": 60},
|
||||
).get("result", {})
|
||||
# exitCode is omitted from the response when it is 0 (zero default).
|
||||
return (
|
||||
int(result.get("exitCode", 0)),
|
||||
_b64(result.get("stdout", "")),
|
||||
_b64(result.get("stderr", "")),
|
||||
)
|
||||
|
||||
def put_file(self, sandbox_id: str, path: str, contents: bytes) -> None:
|
||||
payload = self._request(
|
||||
"POST",
|
||||
f"/v1beta2/sandboxes/{sandbox_id}/files",
|
||||
"addFile",
|
||||
{"filepath": path, "fileContents": base64.b64encode(contents).decode("ascii")},
|
||||
)
|
||||
if not payload.get("success", False):
|
||||
raise SmokeError(f"addFile failed: {payload.get('errorMessage')}")
|
||||
|
||||
def get_file(self, sandbox_id: str, path: str) -> bytes:
|
||||
payload = self._request(
|
||||
"GET",
|
||||
f"/v1beta2/sandboxes/{sandbox_id}/files/{quote(path, safe='')}",
|
||||
"retrieveFile",
|
||||
)
|
||||
return base64.b64decode(payload.get("fileContents", ""))
|
||||
|
||||
def stop(self, sandbox_id: str) -> None:
|
||||
self._request("POST", f"/v1beta2/sandboxes/{sandbox_id}/stop", "stop", {})
|
||||
|
||||
|
||||
def _b64(value: str) -> str:
|
||||
try:
|
||||
return base64.b64decode(value).decode("utf-8", errors="replace") if value else ""
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def _check(failures: list[str], ok: bool, label: str) -> None:
|
||||
print(f" {'✓' if ok else '✗'} {label}")
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default=DEFAULT_IMAGE)
|
||||
parser.add_argument("--keep", action="store_true", help="don't terminate at the end")
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = os.environ.get("CWSANDBOX_API_KEY")
|
||||
if not api_key:
|
||||
print("ERROR: set CWSANDBOX_API_KEY", file=sys.stderr)
|
||||
return 2
|
||||
base_url = os.environ.get("CWSANDBOX_BASE_URL", "https://api.cwsandbox.com")
|
||||
|
||||
client = Client(base_url, api_key)
|
||||
name = f"smoke-{int(time.time())}"
|
||||
print(f"▸ smoke test against {base_url} image={args.image} tag={name}")
|
||||
|
||||
sandbox_id: str | None = None
|
||||
failures: list[str] = []
|
||||
try:
|
||||
print("\n[1/6] provision")
|
||||
sandbox_id = client.provision(image=args.image, name=name)
|
||||
print(f" sandbox_id={sandbox_id}")
|
||||
client.wait_running(sandbox_id)
|
||||
_check(failures, True, "RUNNING")
|
||||
|
||||
print("\n[2/6] unary exec")
|
||||
code, out, _ = client.exec(sandbox_id, 'printf %s "$HOME"; echo; uname -sm')
|
||||
_check(failures, code == 0, "exec exit code 0")
|
||||
_check(failures, out.strip() != "", "exec returned $HOME")
|
||||
|
||||
print("\n[3/6] AddFile (+ read-back via exec)")
|
||||
marker = b"cwsandbox-omnigent-smoke\n"
|
||||
client.put_file(sandbox_id, "/tmp/oa-smoke.txt", marker)
|
||||
_, out, _ = client.exec(sandbox_id, "cat /tmp/oa-smoke.txt")
|
||||
_check(failures, out.encode() == marker, "file readable via exec after AddFile")
|
||||
# RetrieveFile-over-REST can't route an absolute {filepath}; reads
|
||||
# (never needed by the launcher) go through exec+base64 instead.
|
||||
try:
|
||||
ok = client.get_file(sandbox_id, "/tmp/oa-smoke.txt") == marker
|
||||
print(f" {'✓' if ok else 'ℹ'} RetrieveFile REST (bonus): {'works' if ok else 'no'}")
|
||||
except SmokeError:
|
||||
print(" ℹ RetrieveFile REST unsupported for absolute paths (expected)")
|
||||
|
||||
print("\n[4/6] public egress (outbound https from inside)")
|
||||
code, out, _ = client.exec(
|
||||
sandbox_id,
|
||||
'python3 -c "import urllib.request as u; '
|
||||
"print(u.urlopen('https://api.github.com', timeout=15).status)\"",
|
||||
)
|
||||
_check(failures, code == 0 and "200" in out, "outbound HTTPS reached api.github.com")
|
||||
|
||||
print("\n[5/6] detach survives exec session (setsid nohup ... &)")
|
||||
client.exec(sandbox_id, "rm -f /tmp/oa-detach-alive")
|
||||
client.exec(
|
||||
sandbox_id,
|
||||
"setsid nohup sh -c 'sleep 4; echo alive > /tmp/oa-detach-alive' "
|
||||
"> /tmp/oa-detach.log 2>&1 < /dev/null & echo launched",
|
||||
)
|
||||
time.sleep(7)
|
||||
_, out, _ = client.exec(sandbox_id, "cat /tmp/oa-detach-alive 2>/dev/null")
|
||||
_check(failures, out.strip() == "alive", "detached process kept running after exec")
|
||||
|
||||
print("\n[6/6] terminate")
|
||||
if args.keep:
|
||||
print(f" --keep set; leaving {sandbox_id} running")
|
||||
else:
|
||||
client.stop(sandbox_id)
|
||||
_check(failures, True, "stop accepted")
|
||||
sandbox_id = None
|
||||
except SmokeError as exc:
|
||||
failures.append(f"FATAL: {exc}")
|
||||
finally:
|
||||
if sandbox_id is not None and not args.keep:
|
||||
try:
|
||||
client.stop(sandbox_id)
|
||||
print(f"\n (cleaned up {sandbox_id})")
|
||||
except Exception as exc:
|
||||
print(f"\n WARNING: failed to clean up {sandbox_id}: {exc}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if failures:
|
||||
print("SMOKE TEST FAILED:")
|
||||
for f in failures:
|
||||
print(f" ✗ {f}")
|
||||
return 1
|
||||
print("SMOKE TEST PASSED — every managed-host primitive works.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Smoke test for the E2B sandbox provider.
|
||||
|
||||
Drives the REAL :class:`~omnigent.onboarding.sandboxes.e2b.E2BSandboxLauncher`
|
||||
against a live E2B sandbox to validate every primitive the managed-host /
|
||||
CLI-bootstrap flows rely on: provision -> run (incl. the non-zero-exit
|
||||
``CommandExitException`` path) -> put + read-back -> keep_alive ->
|
||||
stream_exec (combined output) -> attach -> public egress -> terminate
|
||||
(idempotent). This is the test that actually exercises the E2B SDK calls
|
||||
the launcher makes, end to end.
|
||||
|
||||
By default it boots from E2B's stock ``base`` template, so it needs NO
|
||||
pre-built omnigent host template — it validates the launcher's SDK wiring
|
||||
in isolation. Pass ``--template omnigent-host`` (after ``e2b template
|
||||
build``; see deploy/e2b/README.md) to smoke the real host template too.
|
||||
|
||||
pip install 'omnigent[e2b]'
|
||||
export E2B_API_KEY=e2b_...
|
||||
python tests/e2e/integrations/deploy/e2b/e2b_smoke_test.py [--template NAME] [--keep]
|
||||
|
||||
Exit code 0 = every primitive worked; 1 = a check failed; 2 = setup error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# The launcher lazy-imports the e2b SDK; surface a clean hint if it (or the
|
||||
# omnigent package) isn't importable rather than a raw traceback.
|
||||
try:
|
||||
from omnigent.onboarding.sandboxes.e2b import (
|
||||
E2BSandboxLauncher,
|
||||
resolve_max_lifetime_s,
|
||||
)
|
||||
except ImportError as exc: # pragma: no cover - environment guard
|
||||
print(f"ERROR: cannot import the launcher ({exc}).", file=sys.stderr)
|
||||
print("Run from the repo root with omnigent installed.", file=sys.stderr)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
|
||||
def _check(failures: list[str], ok: bool, label: str) -> None:
|
||||
"""Record and print one check result."""
|
||||
print(f" {'✓' if ok else '✗'} {label}", flush=True)
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
default="base",
|
||||
help="E2B template to boot from (default: E2B's stock 'base'; pass "
|
||||
"'omnigent-host' to smoke the real host template once built).",
|
||||
)
|
||||
parser.add_argument("--keep", action="store_true", help="don't terminate at the end")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.environ.get("E2B_API_KEY"):
|
||||
print("ERROR: set E2B_API_KEY (https://e2b.dev/dashboard)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# A sentinel env var we inject at provision and read back from inside the
|
||||
# sandbox — exercises the env-passthrough path (resolved from THIS process
|
||||
# env by name, exactly like the server forwards its own environment).
|
||||
marker_name = "OMNIGENT_E2B_SMOKE_MARKER"
|
||||
marker_value = f"smoke-{int(time.time())}"
|
||||
os.environ[marker_name] = marker_value
|
||||
|
||||
launcher = E2BSandboxLauncher(template=args.template, env=[marker_name])
|
||||
name = f"smoke-{int(time.time())}"
|
||||
print(f"▸ E2B launcher smoke template={args.template} tag={name}")
|
||||
|
||||
sandbox_id: str | None = None
|
||||
failures: list[str] = []
|
||||
try:
|
||||
print("\n[1/8] prepare (SDK + credentials)")
|
||||
launcher.prepare()
|
||||
_check(failures, True, "prepare passed")
|
||||
|
||||
print("\n[2/8] provision")
|
||||
sandbox_id = launcher.provision(name)
|
||||
_check(failures, bool(sandbox_id), f"provisioned sandbox_id={sandbox_id}")
|
||||
|
||||
print("\n[3/8] run: exit code, output, and env passthrough")
|
||||
result = launcher.run(sandbox_id, f'echo "$HOME"; printf %s "${marker_name}"', check=True)
|
||||
_check(failures, result.returncode == 0, "run exit code 0")
|
||||
_check(failures, result.stdout.strip() != "", "run captured stdout")
|
||||
_check(failures, marker_value in result.stdout, "injected env var visible inside sandbox")
|
||||
|
||||
print("\n[4/8] run: non-zero exit (CommandExitException path)")
|
||||
# E2B raises CommandExitException on non-zero exit — the launcher must
|
||||
# catch it and surface the code/streams rather than letting it escape.
|
||||
failed = launcher.run(sandbox_id, "echo to-stderr >&2; exit 7", check=False)
|
||||
_check(failures, failed.returncode == 7, "non-zero exit surfaced as returncode 7")
|
||||
_check(failures, "to-stderr" in failed.stderr, "stderr captured on failing command")
|
||||
|
||||
print("\n[5/8] put: ship a binary file and read it back")
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
payload = b"e2b-omnigent-smoke\x00\x01binary\n"
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write(payload)
|
||||
local = Path(tmp.name)
|
||||
try:
|
||||
launcher.put(sandbox_id, local, "/tmp/oa-smoke.bin")
|
||||
finally:
|
||||
local.unlink(missing_ok=True)
|
||||
readback = launcher.run(sandbox_id, "base64 -w0 /tmp/oa-smoke.bin", check=True)
|
||||
import base64
|
||||
|
||||
_check(
|
||||
failures,
|
||||
base64.b64decode(readback.stdout.strip()) == payload,
|
||||
"uploaded file bytes match read-back",
|
||||
)
|
||||
|
||||
print("\n[6/8] keep_alive (set_timeout to max) + attach (connect + is_running)")
|
||||
launcher.keep_alive(sandbox_id) # soft-fail; must not raise
|
||||
_check(failures, True, f"keep_alive extended (target {resolve_max_lifetime_s() // 3600}h)")
|
||||
# Fresh launcher to force a real Sandbox.connect (not the cached handle).
|
||||
E2BSandboxLauncher(template=args.template).attach(sandbox_id)
|
||||
_check(failures, True, "attach validated a running sandbox")
|
||||
|
||||
print("\n[7/8] stream_exec: combined stdout+stderr line stream")
|
||||
proc = launcher.stream_exec(sandbox_id, "echo out-line; echo err-line >&2")
|
||||
streamed = "".join(proc.lines)
|
||||
code = proc.wait()
|
||||
_check(failures, code == 0, "stream_exec wait() exit code 0")
|
||||
_check(
|
||||
failures,
|
||||
"out-line" in streamed and "err-line" in streamed,
|
||||
"stream_exec merged stdout and stderr",
|
||||
)
|
||||
|
||||
print("\n[8/8] public egress (outbound HTTPS from inside)")
|
||||
egress = launcher.run(
|
||||
sandbox_id,
|
||||
'python3 -c "import urllib.request as u; '
|
||||
"print(u.urlopen('https://api.github.com', timeout=15).status)\"",
|
||||
check=False,
|
||||
)
|
||||
_check(failures, "200" in egress.stdout, "outbound HTTPS reached api.github.com")
|
||||
|
||||
except Exception as exc:
|
||||
failures.append(f"FATAL: {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
if sandbox_id is not None and not args.keep:
|
||||
print("\n[cleanup] terminate (idempotent)")
|
||||
try:
|
||||
launcher.terminate(sandbox_id)
|
||||
# A second terminate of an already-killed sandbox must be a no-op.
|
||||
launcher.terminate(sandbox_id)
|
||||
print(f" ✓ terminated {sandbox_id} (and second call was a no-op)")
|
||||
except Exception as exc:
|
||||
print(f" WARNING: cleanup failed for {sandbox_id}: {exc}")
|
||||
elif sandbox_id is not None:
|
||||
print(f"\n[cleanup] --keep set; leaving {sandbox_id} running")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if failures:
|
||||
print("SMOKE TEST FAILED:")
|
||||
for f in failures:
|
||||
print(f" ✗ {f}")
|
||||
return 1
|
||||
print("SMOKE TEST PASSED — every E2B launcher primitive works against a live sandbox.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user