206 lines
6.1 KiB
Python
206 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Sequence
|
|
|
|
import pytest
|
|
|
|
from a2a_pack import (
|
|
A2AAgent,
|
|
ExecResult,
|
|
LocalRunContext,
|
|
NoAuth,
|
|
RunContext,
|
|
SandboxClient,
|
|
SandboxHandle,
|
|
SandboxSpec,
|
|
SandboxUnavailable,
|
|
skill,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# stub client that records calls — for asserting the surface without needing
|
|
# microsandbox/FUSE/MinIO running.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _StubHandle(SandboxHandle):
|
|
def __init__(self, name: str, exec_log: list[tuple[str, ...]]) -> None:
|
|
self.name = name
|
|
self._exec_log = exec_log
|
|
self.stopped = False
|
|
|
|
async def exec(
|
|
self,
|
|
cmd: str,
|
|
args: Sequence[str] | None = None,
|
|
*,
|
|
timeout: float | None = None,
|
|
) -> ExecResult:
|
|
a = tuple(args or ())
|
|
self._exec_log.append(("exec", cmd, *a))
|
|
return ExecResult(stdout=f"exec:{cmd}:{','.join(a)}", exit_code=0)
|
|
|
|
async def shell(
|
|
self, script: str, *, timeout: float | None = None
|
|
) -> ExecResult:
|
|
self._exec_log.append(("shell", script))
|
|
return ExecResult(stdout=f"shell:{script}", exit_code=0)
|
|
|
|
async def stop(self) -> None:
|
|
self.stopped = True
|
|
|
|
async def kill(self) -> None:
|
|
self.stopped = True
|
|
|
|
async def logs(self, *, tail: int | None = None) -> str:
|
|
return ""
|
|
|
|
|
|
class _StubClient(SandboxClient):
|
|
def __init__(self) -> None:
|
|
self.created: list[SandboxSpec] = []
|
|
self.removed: list[str] = []
|
|
self._handles: dict[str, _StubHandle] = {}
|
|
self.exec_log: list[tuple[str, ...]] = []
|
|
|
|
async def create(self, spec: SandboxSpec) -> SandboxHandle:
|
|
self.created.append(spec)
|
|
h = _StubHandle(spec.name, self.exec_log)
|
|
self._handles[spec.name] = h
|
|
return h
|
|
|
|
async def get(self, name: str) -> SandboxHandle:
|
|
return self._handles[name]
|
|
|
|
async def list(self) -> list[str]:
|
|
return list(self._handles)
|
|
|
|
async def remove(self, name: str) -> None:
|
|
self.removed.append(name)
|
|
self._handles.pop(name, None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# context plumbing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_sandbox_unavailable_when_not_attached():
|
|
ctx: LocalRunContext[NoAuth] = LocalRunContext(auth=NoAuth())
|
|
with pytest.raises(SandboxUnavailable):
|
|
_ = ctx.sandbox
|
|
|
|
|
|
async def test_sandbox_attached_returns_client():
|
|
client = _StubClient()
|
|
ctx: LocalRunContext[NoAuth] = LocalRunContext(auth=NoAuth(), sandbox=client)
|
|
assert ctx.sandbox is client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# agent uses ctx.sandbox via convenience helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _CoderAgent(A2AAgent):
|
|
name = "coder"
|
|
description = "runs code in a sandbox"
|
|
|
|
@skill()
|
|
async def run(self, ctx: RunContext[NoAuth], code: str) -> str:
|
|
result = await ctx.sandbox.run_python(code)
|
|
return result.output
|
|
|
|
|
|
async def test_agent_uses_run_python_convenience():
|
|
client = _StubClient()
|
|
out = await _CoderAgent().local_invoke(
|
|
"run", sandbox=client, code="print('hello')"
|
|
)
|
|
assert out.startswith("exec:python:-c,print('hello')")
|
|
# Spec was created and torn down (ephemeral)
|
|
assert len(client.created) == 1
|
|
assert client.created[0].image == "python:3.11-slim"
|
|
assert client.removed == [client.created[0].name]
|
|
|
|
|
|
async def test_run_shell_one_shot():
|
|
client = _StubClient()
|
|
|
|
class _Agent(A2AAgent):
|
|
name = "shellbot"
|
|
description = ""
|
|
|
|
@skill()
|
|
async def go(self, ctx: RunContext[NoAuth]) -> str:
|
|
r = await ctx.sandbox.run_shell("ls /workspace")
|
|
return r.stdout
|
|
|
|
out = await _Agent().local_invoke("go", sandbox=client)
|
|
assert "shell:ls /workspace" in out
|
|
assert client.exec_log == [("shell", "ls /workspace")]
|
|
# one-shot: created and removed in the same call
|
|
assert len(client.created) == 1
|
|
assert len(client.removed) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# explicit lifecycle (matches microsandbox SDK shape 1:1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_explicit_create_and_stop():
|
|
client = _StubClient()
|
|
|
|
class _Agent(A2AAgent):
|
|
name = "lifecycle"
|
|
description = ""
|
|
|
|
@skill()
|
|
async def go(self, ctx: RunContext[NoAuth]) -> int:
|
|
sb = await ctx.sandbox.create(
|
|
SandboxSpec(name="my-sb", image="python:3.11-slim", workspace="agent-foo")
|
|
)
|
|
r1 = await sb.exec("python", ["-c", "print(1)"])
|
|
r2 = await sb.shell("echo 2")
|
|
await sb.stop()
|
|
assert isinstance(r1, ExecResult)
|
|
assert r2.ok
|
|
return r1.exit_code
|
|
|
|
rc = await _Agent().local_invoke("go", sandbox=client)
|
|
assert rc == 0
|
|
assert client.created[0].workspace == "agent-foo"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SandboxSpec metadata propagation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_spec_carries_secrets_and_egress():
|
|
client = _StubClient()
|
|
|
|
class _Agent(A2AAgent):
|
|
name = "scoped"
|
|
description = ""
|
|
|
|
@skill()
|
|
async def go(self, ctx: RunContext[NoAuth]) -> str:
|
|
spec = SandboxSpec(
|
|
name="net-bound",
|
|
secrets=("OPENAI_KEY",),
|
|
egress=("api.openai.com",),
|
|
labels={"task": "research"},
|
|
)
|
|
sb = await ctx.sandbox.create(spec)
|
|
await sb.stop()
|
|
return "ok"
|
|
|
|
await _Agent().local_invoke("go", sandbox=client)
|
|
spec = client.created[0]
|
|
assert spec.secrets == ("OPENAI_KEY",)
|
|
assert spec.egress == ("api.openai.com",)
|
|
assert spec.labels == {"task": "research"}
|