ship grants, a2a_client, discovery, sandbox SDK + tests

This commit is contained in:
robert
2026-05-09 12:43:07 -03:00
parent b6f6cd1643
commit 2dcb8a09cd
15 changed files with 1853 additions and 75 deletions

View File

@@ -1,3 +1,9 @@
from .a2a_client import (
A2AClient,
CallResult,
HttpA2AClient,
InMemoryA2AClient,
)
from .agent import (
A2AAgent,
ParamSpec,
@@ -7,6 +13,13 @@ from .agent import (
SkillSpec,
skill,
)
from .discovery import (
ControlPlaneDiscovery,
DiscoveredAgent,
DiscoveryClient,
InMemoryDiscovery,
)
from .grants import Grant, GrantInvalid, mint_grant, sign_grant, verify_grant
from .auth import APIKeyAuth, JWTAuth, NoAuth
from .card import AgentCard, SkillCard
from .context import (
@@ -26,6 +39,13 @@ from .runtime import (
SkillPolicy,
State,
)
from .sandbox import (
ExecResult,
SandboxClient,
SandboxHandle,
SandboxSpec,
SandboxUnavailable,
)
from .workspace import (
FileMatch,
FileType,
@@ -42,15 +62,26 @@ from .workspace import (
__all__ = [
"A2AAgent",
"A2AClient",
"APIKeyAuth",
"AgentCard",
"AgentEvent",
"AgentRuntime",
"ArtifactRef",
"CallResult",
"CancelledByCaller",
"ControlPlaneDiscovery",
"DiscoveredAgent",
"DiscoveryClient",
"EgressPolicy",
"ExecResult",
"FileMatch",
"FileType",
"Grant",
"GrantInvalid",
"HttpA2AClient",
"InMemoryA2AClient",
"InMemoryDiscovery",
"JWTAuth",
"Lifecycle",
"LocalRunContext",
@@ -62,6 +93,13 @@ __all__ = [
"Resources",
"RunContext",
"Sandbox",
"mint_grant",
"sign_grant",
"verify_grant",
"SandboxClient",
"SandboxHandle",
"SandboxSpec",
"SandboxUnavailable",
"SkillCard",
"SkillInputError",
"SkillInvocationError",

170
a2a_pack/a2a_client.py Normal file
View File

@@ -0,0 +1,170 @@
"""Agent-to-agent invocation surface available via ``ctx.call(...)``.
An agent never speaks raw HTTP to another agent. It calls
``ctx.call(target, skill, args, grant=...)`` and the runtime-attached
:class:`A2AClient` handles transport: HTTP for cross-pod, in-memory for
local tests, anything else (gRPC, message bus) for future runtimes.
The grant token (see :mod:`a2a_pack.grants`) is the *only* way to hand
workspace access across agents. Callee-side runtime validates it before
materializing a :class:`WorkspaceClient`.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .agent import A2AAgent
from .context import RunContext
@dataclass(frozen=True)
class CallResult:
"""What an A2A invocation returns to the calling skill."""
result: Any
events: tuple[dict[str, Any], ...] = ()
artifacts: tuple[dict[str, Any], ...] = ()
grant_id: str | None = None # echoed for audit
class A2AClient(ABC):
"""Transport-shaped agent-to-agent client."""
@abstractmethod
async def call(
self,
target: str,
skill: str,
*,
args: dict[str, Any] | None = None,
grant: str | None = None,
timeout: float | None = None,
) -> CallResult:
"""Invoke ``skill`` on ``target`` and return its :class:`CallResult`.
``target`` is opaque to this layer — for the HTTP impl it's an agent
URL; for the in-memory impl it's an agent name.
"""
# ---------------------------------------------------------------------------
# In-memory: routes calls to A2AAgent instances in the same process. Useful
# for the demo + tests.
# ---------------------------------------------------------------------------
@dataclass
class InMemoryA2AClient(A2AClient):
"""Routes calls to agent instances registered by name.
The receiving agent gets a *new* :class:`RunContext` built by the
``ctx_factory`` callable, so caller and callee don't share state.
Pass ``ctx_factory=lambda agent, grant: ...`` to control how scoped
workspaces / sandboxes are wired in.
"""
agents: dict[str, "A2AAgent"]
ctx_factory: Any = None # Callable[[A2AAgent, str | None], RunContext]
async def call(
self,
target: str,
skill: str,
*,
args: dict[str, Any] | None = None,
grant: str | None = None,
timeout: float | None = None,
) -> CallResult:
if target not in self.agents:
raise KeyError(f"no agent registered: {target!r}")
agent = self.agents[target]
ctx = self.ctx_factory(agent, grant) if self.ctx_factory else None
if ctx is None:
from .context import LocalRunContext
from .auth import NoAuth
ctx = LocalRunContext(auth=NoAuth(), task_id=f"a2a-{target}")
result = await agent.invoke_json(skill, ctx, args or {})
events = tuple(
{"kind": e.kind, "payload": e.payload}
for e in getattr(ctx, "events", ())
)
# surface artifacts captured by LocalRunContext, if present
artifacts: tuple[dict[str, Any], ...] = ()
local_arts = getattr(ctx, "artifacts", None)
if isinstance(local_arts, dict):
artifacts = tuple(
{"name": name, "size_bytes": len(data)}
for name, data in local_arts.items()
)
return CallResult(
result=result,
events=events,
artifacts=artifacts,
grant_id=_grant_id_or_none(grant),
)
# ---------------------------------------------------------------------------
# HTTP: posts to <target>/invoke/<skill> with {arguments, grant} body.
# ---------------------------------------------------------------------------
@dataclass
class HttpA2AClient(A2AClient):
"""A2A client that POSTs to the standard /invoke/{skill} endpoint."""
default_timeout: float = 60.0
async def call(
self,
target: str,
skill: str,
*,
args: dict[str, Any] | None = None,
grant: str | None = None,
timeout: float | None = None,
) -> CallResult:
import httpx # late import: server-side needs no client
body: dict[str, Any] = {"arguments": args or {}}
if grant is not None:
body["grant"] = grant
url = f"{target.rstrip('/')}/invoke/{skill}"
async with httpx.AsyncClient(timeout=timeout or self.default_timeout) as c:
resp = await c.post(url, json=body)
if resp.status_code >= 400:
raise RuntimeError(f"a2a {url} -> {resp.status_code}: {resp.text}")
data = resp.json()
return CallResult(
result=data.get("result"),
events=tuple(data.get("events") or ()),
artifacts=tuple(data.get("artifacts") or ()),
grant_id=_grant_id_or_none(grant),
)
def _grant_id_or_none(grant: str | None) -> str | None:
"""Extract grant_id without re-validating the signature (audit only)."""
if not grant or "." not in grant:
return None
try:
from .grants import _b64decode
payload = _b64decode(grant.rsplit(".", 1)[0])
import json
return json.loads(payload).get("grant_id")
except Exception: # noqa: BLE001
return None
__all__ = [
"A2AClient",
"CallResult",
"HttpA2AClient",
"InMemoryA2AClient",
]

View File

@@ -372,19 +372,26 @@ class A2AAgent(Generic[ConfigT, AuthT], metaclass=_AgentMeta):
secrets: dict[str, str] | None = None,
task_id: str = "local-task",
workspace: Any = None, # WorkspaceClient or None
sandbox: Any = None, # SandboxClient or None
a2a: Any = None, # A2AClient or None
discover: Any = None, # DiscoveryClient or None
**kwargs: Any,
) -> Any:
"""Convenience harness: build a :class:`LocalRunContext` and invoke.
Useful in tests and notebooks. ``auth`` defaults to a default-constructed
instance of the agent's ``auth_model`` (works for :class:`NoAuth`; pass
an explicit instance for auth models with required fields). Pass
``workspace=`` to bind a :class:`WorkspaceClient`.
Useful in tests and notebooks. Pass ``workspace=``, ``sandbox=``,
``a2a=``, and/or ``discover=`` to bind concrete runtime clients.
"""
if auth is None:
auth = typing.cast(AuthT, type(self).auth_model())
ctx: LocalRunContext[AuthT] = LocalRunContext(
auth=auth, secrets=secrets, task_id=task_id, workspace=workspace
auth=auth,
secrets=secrets,
task_id=task_id,
workspace=workspace,
sandbox=sandbox,
a2a=a2a,
discover=discover,
)
return await self.invoke(skill_name, ctx, **kwargs)

View File

@@ -88,6 +88,37 @@ class ControlPlaneClient:
},
)
def from_tarball(
self,
*,
name: str,
version: str,
entrypoint: str,
description: str,
public: bool,
tarball: bytes,
) -> dict[str, Any]:
with httpx.Client(timeout=120.0) as c:
resp = c.post(
f"{self.api_url}/v1/agents/from-tarball",
headers={"authorization": f"bearer {self.token}"} if self.token else {},
data={
"name": name,
"version": version,
"entrypoint": entrypoint,
"description": description,
"public": str(public).lower(),
},
files={"source": ("source.tar.gz", tarball, "application/gzip")},
)
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception: # noqa: BLE001
detail = resp.text
raise ApiError(resp.status_code, str(detail))
return resp.json()
def list_agents(self) -> list[dict[str, Any]]:
return self._request("GET", "/v1/agents")

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
@@ -204,16 +205,6 @@ def init(
"a2a.yaml.tmpl", name=name, class_name=class_name
),
"requirements.txt": _render_template("requirements.txt.tmpl"),
"Dockerfile": _render_template(
"Dockerfile.tmpl", entrypoint=f"agent:{class_name}"
),
".dockerignore": _render_template("dockerignore.tmpl"),
".gitea/workflows/build.yml": _render_template(
"workflow.yml.tmpl", name=name
),
"deploy/20-deployment.yaml": _render_template(
"deployment.yaml.tmpl", name=name
),
}
for relpath, content in files.items():
target_path = project / relpath
@@ -323,60 +314,71 @@ def build(
console.print(f"[green]pushed[/] [cyan]{image}[/]")
def _git_push_source(project: Path, push_url: str) -> None:
"""Initialize the project as a git repo (if needed) and push to ``push_url``.
def _make_tarball(project: Path) -> bytes:
"""Tar.gz the user's project directory.
Idempotent: re-running on an existing repo just commits any changes
and pushes.
Excludes platform/dev artifacts so build images stay small. The
platform stamps in Dockerfile/workflow/manifests on the server side.
"""
git_dir = project / ".git"
if not git_dir.exists():
_run(["git", "-C", str(project), "init", "-q", "-b", "main"])
_run(["git", "-C", str(project), "config", "user.email", "agent@a2a.local"])
_run(["git", "-C", str(project), "config", "user.name", "agent"])
# Ensure remote
have_remote = subprocess.run(
["git", "-C", str(project), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=False,
).returncode == 0
if have_remote:
_run(["git", "-C", str(project), "remote", "set-url", "origin", push_url])
else:
_run(["git", "-C", str(project), "remote", "add", "origin", push_url])
_run(["git", "-C", str(project), "add", "-A"])
status = subprocess.run(
["git", "-C", str(project), "status", "--porcelain"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
if status:
_run(["git", "-C", str(project), "commit", "-q", "-m", "deploy"])
# Pull --rebase first to integrate any auto-bump commits the runner pushed back.
subprocess.run(
["git", "-C", str(project), "pull", "--rebase", "origin", "main"],
capture_output=True,
text=True,
check=False,
)
_run(["git", "-C", str(project), "push", "-u", "origin", "main"])
import io
import tarfile
excluded_dirs = {
"__pycache__",
".venv",
".git",
".pytest_cache",
".mypy_cache",
"node_modules",
"dist",
"build",
".gitea",
"deploy",
}
excluded_files = {"Dockerfile", ".dockerignore"}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for root, dirs, files in os.walk(project):
dirs[:] = [d for d in dirs if d not in excluded_dirs]
for fname in files:
if fname in excluded_files or fname.endswith(".pyc"):
continue
fpath = Path(root) / fname
arcname = fpath.relative_to(project)
tar.add(fpath, arcname=str(arcname))
return buf.getvalue()
def _wait_for_url(url: str, timeout: int = 180) -> bool:
import time
import httpx
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with httpx.Client(timeout=3.0) as c:
if c.get(f"{url}/healthz").status_code == 200:
return True
except Exception: # noqa: BLE001
pass
time.sleep(4)
return False
@app.command()
def deploy(
project: Path = typer.Option(Path("."), "--project", "-p"),
public: bool | None = typer.Option(None, "--public/--private"),
wait: bool = typer.Option(True, "--wait/--no-wait", help="Poll until URL is live"),
api: str | None = typer.Option(None, "--api", help="Override control plane URL"),
) -> None:
"""Push source to gitea; the platform builds + deploys.
"""Ship the agent.
No local docker. The CLI:
1. asks the control plane to provision a gitea repo + ArgoCD app
2. ``git push``es the project source to that repo
The Gitea Actions runner builds the image and ArgoCD reconciles
``deploy/`` onto the cluster. Returns the public URL once registered.
Tarballs your source, uploads to the control plane, and prints the
URL when it's live. No local docker. No git. No knowledge of how the
platform builds or deploys.
"""
creds = credentials.load()
if creds is None:
@@ -389,33 +391,29 @@ def deploy(
)
description = cfg.get("description", cls.description or "")
if not (project / "deploy").exists() or not (project / ".gitea/workflows/build.yml").exists():
_fail(
"project missing deploy/ or .gitea/workflows/. "
"Re-run `a2a init` or upgrade the scaffold."
)
console.print("[dim]packaging source...[/]")
tarball = _make_tarball(project)
size_kb = len(tarball) / 1024
console.print(f"[dim]uploading {size_kb:.1f}KB to {credentials.resolve_api_url(api)}...[/]")
client = _client(api)
console.print(f"[dim]asking control plane to provision repo + argo app...[/]")
try:
prov = client.from_source(
out = client.from_tarball(
name=cls.name,
description=description,
version=cfg["version"],
entrypoint=cfg["entrypoint"],
description=description,
public=is_public,
tarball=tarball,
)
except ApiError as exc:
_fail(str(exc))
console.print(f"[dim]pushing source → {prov['repo_url']}[/]")
_git_push_source(project, prov["push_url"])
summary = {
"agent": prov["name"],
"repo": prov["repo_url"],
"url": prov.get("expected_url"),
"card": f"{prov['expected_url']}/.well-known/agent-card" if prov.get("expected_url") else None,
"next": "the runner is building. `a2a agents` to check status, or curl the url in ~30s",
"agent": out["name"],
"version": out["version"],
"status": out["status"],
"url": out.get("url"),
}
console.print(
Panel.fit(
@@ -424,6 +422,17 @@ def deploy(
)
)
url = out.get("url")
if wait and url:
console.print(f"[dim]waiting for {url} ...[/]")
ready = _wait_for_url(url)
if ready:
console.print(f"[green]live[/]: {url}")
else:
console.print(
f"[yellow]still building[/]; check `a2a agents` or curl {url} in a bit"
)
# Used as `python -m a2a_pack.cli.main`
if __name__ == "__main__": # pragma: no cover

View File

@@ -13,6 +13,9 @@ from typing import Any, Generic, Sequence, TypeVar
from pydantic import BaseModel
from .a2a_client import A2AClient, CallResult
from .discovery import DiscoveryClient
from .sandbox import SandboxClient, SandboxUnavailable
from .workspace import WorkspaceClient
AuthT = TypeVar("AuthT", bound=BaseModel)
@@ -88,6 +91,43 @@ class RunContext(ABC, Generic[AuthT]):
Raises if the agent's :attr:`A2AAgent.workspace_access` is disabled.
"""
@property
@abstractmethod
def sandbox(self) -> SandboxClient:
"""Code-execution surface (microsandbox-backed by default).
Raises :class:`SandboxUnavailable` if the runtime did not attach a
sandbox client to this context (e.g. local dev with no host daemon).
"""
@property
@abstractmethod
def discover(self) -> DiscoveryClient:
"""Registry-backed discovery: find other agents by tag/capability/skill."""
async def call(
self,
target: str,
skill: str,
*,
args: dict[str, Any] | None = None,
grant: str | None = None,
timeout: float | None = None,
) -> CallResult:
"""Invoke another agent's skill via the runtime's :class:`A2AClient`.
``target`` is whatever the underlying client expects — an HTTP URL
for :class:`HttpA2AClient`, an agent name for in-process routing.
Pair with :meth:`WorkspaceClient.delegate` to hand a scoped
workspace grant to the callee.
"""
client = self._a2a_client()
return await client.call(target, skill, args=args, grant=grant, timeout=timeout)
@abstractmethod
def _a2a_client(self) -> A2AClient:
"""Return the runtime's outbound A2A client (or raise if absent)."""
# --- concrete helpers built on emit_event ---
async def emit_progress(self, message: str) -> None:
@@ -146,11 +186,17 @@ class LocalRunContext(RunContext[AuthT]):
task_id: str = "local-task",
secrets: dict[str, str] | None = None,
workspace: WorkspaceClient | None = None,
sandbox: SandboxClient | None = None,
a2a: A2AClient | None = None,
discover: DiscoveryClient | None = None,
) -> None:
self.task_id = task_id
self.auth = auth
self._secrets: dict[str, str] = dict(secrets or {})
self._workspace = workspace
self._sandbox = sandbox
self._a2a = a2a
self._discover = discover
self._cancel = asyncio.Event()
self.events: list[AgentEvent] = []
self.artifacts: dict[str, bytes] = {}
@@ -164,6 +210,31 @@ class LocalRunContext(RunContext[AuthT]):
)
return self._workspace
@property
def sandbox(self) -> SandboxClient:
if self._sandbox is None:
raise SandboxUnavailable(
"no sandbox client attached to this context; "
"the runtime layer must provision one"
)
return self._sandbox
@property
def discover(self) -> DiscoveryClient:
if self._discover is None:
raise PermissionError(
"no discovery client attached; runtime must provision one"
)
return self._discover
def _a2a_client(self) -> A2AClient:
if self._a2a is None:
raise PermissionError(
"no A2A client attached; runtime must provision one before "
"ctx.call(...) can be used"
)
return self._a2a
async def emit_event(self, event: AgentEvent) -> None:
self.events.append(event)

176
a2a_pack/discovery.py Normal file
View File

@@ -0,0 +1,176 @@
"""Agent discovery surface available via ``ctx.discover``.
Agents find each other by capability/tag/skill — *never* by hardcoded URL.
The runtime attaches a :class:`DiscoveryClient`; the canonical impl
queries the platform's agent registry (control plane).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Sequence
from .card import AgentCard
@dataclass(frozen=True)
class DiscoveredAgent:
"""A registry hit. ``url`` is what the caller hands to ``ctx.call``."""
name: str
url: str | None
card: AgentCard
class DiscoveryClient(ABC):
"""Discovery surface."""
@abstractmethod
async def find_agents(
self,
*,
tags: Sequence[str] = (),
capability: str | None = None,
skill: str | None = None,
limit: int = 10,
) -> list[DiscoveredAgent]: ...
@abstractmethod
async def get_agent(self, name: str) -> DiscoveredAgent: ...
# ---------------------------------------------------------------------------
# In-memory impl: a name → AgentCard map. Used by tests + the demo.
# ---------------------------------------------------------------------------
class InMemoryDiscovery(DiscoveryClient):
def __init__(self, agents: dict[str, DiscoveredAgent]) -> None:
self._agents = dict(agents)
async def find_agents(
self,
*,
tags: Sequence[str] = (),
capability: str | None = None,
skill: str | None = None,
limit: int = 10,
) -> list[DiscoveredAgent]:
wanted = {t.lower() for t in tags}
out: list[DiscoveredAgent] = []
for da in self._agents.values():
if capability and capability not in da.card.capabilities:
continue
if skill and not any(s.name == skill for s in da.card.skills):
continue
if wanted:
skill_tags = {
t.lower() for s in da.card.skills for t in s.tags
}
if not (wanted & skill_tags):
continue
out.append(da)
if len(out) >= limit:
break
return out
async def get_agent(self, name: str) -> DiscoveredAgent:
if name not in self._agents:
raise KeyError(f"no agent: {name!r}")
return self._agents[name]
# ---------------------------------------------------------------------------
# Control-plane backed: queries GET /v1/agents (with optional ?tag=, ?skill=).
# ---------------------------------------------------------------------------
class ControlPlaneDiscovery(DiscoveryClient):
"""Hits the platform's agent registry (control plane)."""
def __init__(
self,
api_url: str,
*,
token: str | None = None,
timeout: float = 10.0,
) -> None:
self.api_url = api_url.rstrip("/")
self._token = token
self._timeout = timeout
def _headers(self) -> dict[str, str]:
h = {"accept": "application/json"}
if self._token:
h["authorization"] = f"bearer {self._token}"
return h
async def find_agents(
self,
*,
tags: Sequence[str] = (),
capability: str | None = None,
skill: str | None = None,
limit: int = 10,
) -> list[DiscoveredAgent]:
import httpx
params: dict[str, Any] = {"limit": limit}
if tags:
params["tag"] = list(tags)
if capability:
params["capability"] = capability
if skill:
params["skill"] = skill
async with httpx.AsyncClient(timeout=self._timeout) as c:
resp = await c.get(
f"{self.api_url}/v1/agents", headers=self._headers(), params=params
)
resp.raise_for_status()
rows = resp.json()
out: list[DiscoveredAgent] = []
for row in rows or []:
# Lazy: list endpoint returns AgentOut (no card). Fetch full card.
try:
card_url = row.get("url")
if card_url:
full = await c.get(f"{card_url}/.well-known/agent-card")
full.raise_for_status()
card = AgentCard.model_validate(full.json())
else:
detail = await c.get(
f"{self.api_url}/v1/agents/{row['name']}",
headers=self._headers(),
)
detail.raise_for_status()
card = AgentCard.model_validate(detail.json().get("card") or {})
except Exception: # noqa: BLE001
continue
out.append(
DiscoveredAgent(
name=row.get("name", ""),
url=row.get("url"),
card=card,
)
)
return out
async def get_agent(self, name: str) -> DiscoveredAgent:
import httpx
async with httpx.AsyncClient(timeout=self._timeout) as c:
resp = await c.get(
f"{self.api_url}/v1/agents/{name}", headers=self._headers()
)
resp.raise_for_status()
data = resp.json()
card = AgentCard.model_validate(data.get("card") or {})
return DiscoveredAgent(name=data["name"], url=data.get("url"), card=card)
__all__ = [
"ControlPlaneDiscovery",
"DiscoveredAgent",
"DiscoveryClient",
"InMemoryDiscovery",
]

148
a2a_pack/grants.py Normal file
View File

@@ -0,0 +1,148 @@
"""Signed grant tokens for cross-agent workspace handoff.
A grant is a small, self-contained, signed claim issued by one agent that
the platform (or the receiving agent) can verify without a registry round-trip.
Wire format::
"<base64url(json(payload))>.<base64url(hmac_sha256(secret, payload))>"
The payload describes *what* the callee is allowed to do, *whose* workspace
they can see, and *for how long*. The runtime on the receiving side
materializes a :class:`WorkspaceClient` scoped to that grant.
Auth model is intentionally simple for v1: a shared platform secret signs
every grant. Swap for asymmetric (X.509 / JWKS) when crossing trust domains.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt
from .workspace import WorkspaceMode
DEFAULT_TTL_SECONDS = 5 * 60
class GrantInvalid(PermissionError):
"""Raised by :func:`verify_grant` when a grant is bad/expired/forged."""
class Grant(BaseModel):
"""The payload of a signed grant token.
A grant binds *who* (issuer) gave *whom* (audience) access to *which*
workspace files (bucket + allow/deny patterns) under *what* mode and
*how long*. The runtime enforces every line of this payload.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
grant_id: str
issuer: str # caller agent name or URL
audience: str # callee agent name or URL
bucket: str # workspace bucket the grant covers
mode: WorkspaceMode = WorkspaceMode.READ_ONLY
allow_patterns: tuple[str, ...] = ("**",)
deny_patterns: tuple[str, ...] = ()
outputs_prefix: str | None = None # if set, callee writes only here
expires_at: NonNegativeInt = 0
issued_at: NonNegativeInt = 0
nonce: str = Field(default_factory=lambda: secrets.token_hex(8))
def _b64encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
def _b64decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def _platform_secret() -> bytes:
secret = os.environ.get("A2A_PLATFORM_SECRET", "dev-secret-rotate-me")
return secret.encode("utf-8")
def mint_grant(
*,
issuer: str,
audience: str,
bucket: str,
mode: WorkspaceMode = WorkspaceMode.READ_ONLY,
allow_patterns: tuple[str, ...] = ("**",),
deny_patterns: tuple[str, ...] = (),
outputs_prefix: str | None = None,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
secret: bytes | None = None,
) -> tuple[Grant, str]:
"""Build a :class:`Grant` and return it together with its signed token."""
now = int(time.time())
grant = Grant(
grant_id=secrets.token_hex(8),
issuer=issuer,
audience=audience,
bucket=bucket,
mode=mode,
allow_patterns=tuple(allow_patterns),
deny_patterns=tuple(deny_patterns),
outputs_prefix=outputs_prefix,
expires_at=now + ttl_seconds,
issued_at=now,
)
return grant, sign_grant(grant, secret=secret)
def sign_grant(grant: Grant, *, secret: bytes | None = None) -> str:
payload = grant.model_dump_json(exclude_none=False).encode("utf-8")
sig = hmac.new(secret or _platform_secret(), payload, hashlib.sha256).digest()
return f"{_b64encode(payload)}.{_b64encode(sig)}"
def verify_grant(token: str, *, secret: bytes | None = None) -> Grant:
"""Parse + verify ``token``. Raises :class:`GrantInvalid` on any failure.
Checks signature, expiry, and minimal structural shape. Caller-specific
audience checks are layered on top by the server adapter.
"""
if not token or "." not in token:
raise GrantInvalid("malformed grant token")
payload_b64, sig_b64 = token.rsplit(".", 1)
try:
payload = _b64decode(payload_b64)
sig = _b64decode(sig_b64)
except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined]
raise GrantInvalid(f"grant decode failed: {exc}") from exc
expected = hmac.new(secret or _platform_secret(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(expected, sig):
raise GrantInvalid("grant signature mismatch")
try:
data = json.loads(payload)
grant = Grant.model_validate(data)
except Exception as exc: # noqa: BLE001
raise GrantInvalid(f"grant payload invalid: {exc}") from exc
if grant.expires_at and grant.expires_at < int(time.time()):
raise GrantInvalid(f"grant expired at {grant.expires_at}")
return grant
__all__ = [
"Grant",
"GrantInvalid",
"mint_grant",
"sign_grant",
"verify_grant",
"DEFAULT_TTL_SECONDS",
]

174
a2a_pack/sandbox.py Normal file
View File

@@ -0,0 +1,174 @@
"""Code-execution sandbox surface available to agents via ``ctx.sandbox``.
The abstract :class:`SandboxClient` is what agent code programs against. The
runtime layer (host-side microsandbox + FUSE-mounted MinIO, in-cluster
DaemonSet, hosted SaaS) supplies a concrete implementation.
The sandbox is **general-purpose code execution**, not Python-only. Agents
can:
* run arbitrary shell pipelines: ``await ctx.sandbox.run_shell("git clone … && cargo build")``
* exec a binary with explicit args (no shell parsing): ``await sb.exec("/usr/bin/git", ["clone", url])``
* pick any OCI image: ``run_shell("npx @openai/codex …", image="node:20-slim")``
``run_python`` is just a convenience for the common Python-snippet case.
Why an abstract here when ``microsandbox`` itself already has a Python SDK?
The platform owns the *policy* layer — bucket selection, network egress,
write-path restrictions, resource caps, audit logging. Agents must depend on
the policy-respecting surface, not on the raw SDK, so the same agent code
runs unchanged across local dev / cluster / hosted environments.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Sequence
@dataclass(frozen=True)
class ExecResult:
"""Result of a command run inside a sandbox."""
stdout: str
stderr: str = ""
exit_code: int = 0
truncated: bool = False
@property
def output(self) -> str:
"""Convenience: combined stdout+stderr."""
return self.stdout + (self.stderr or "")
@property
def ok(self) -> bool:
return self.exit_code == 0
@dataclass(frozen=True)
class SandboxSpec:
"""Caller request shape for :meth:`SandboxClient.create`."""
name: str
image: str = "python:3.11-slim"
memory_mib: int = 512
cpus: int = 1
# If set, the runtime mounts this workspace at ``/workspace`` inside the
# VM (FUSE-backed where supported, snapshot bridge otherwise).
workspace: str | None = None
# Logical names the runtime should resolve to actual secrets and inject
# into the VM env. Values never appear in the CLI/API surface.
secrets: tuple[str, ...] = ()
# Egress allowlist by hostname; empty = deny all.
egress: tuple[str, ...] = ()
# Free-form labels for audit / billing.
labels: dict[str, str] = field(default_factory=dict)
class SandboxHandle(ABC):
"""Live handle to a running sandbox VM."""
name: str
@abstractmethod
async def exec(
self,
cmd: str,
args: Sequence[str] | None = None,
*,
timeout: float | None = None,
) -> ExecResult: ...
@abstractmethod
async def shell(
self, script: str, *, timeout: float | None = None
) -> ExecResult: ...
@abstractmethod
async def stop(self) -> None: ...
@abstractmethod
async def kill(self) -> None: ...
@abstractmethod
async def logs(self, *, tail: int | None = None) -> str: ...
class SandboxClient(ABC):
"""Negotiation surface handed to agents via ``ctx.sandbox``."""
@abstractmethod
async def create(self, spec: SandboxSpec) -> SandboxHandle: ...
@abstractmethod
async def get(self, name: str) -> SandboxHandle: ...
@abstractmethod
async def list(self) -> list[str]: ...
@abstractmethod
async def remove(self, name: str) -> None: ...
async def run_python(
self, code: str, *, image: str = "python:3.11-slim", **kwargs: Any
) -> ExecResult:
"""Convenience: spin a one-shot sandbox, run inline Python, tear down.
Equivalent to ``create(SandboxSpec(image=image)).exec("python", ["-c", code])``.
Use the lower-level surface when you need persistence, multiple
commands, or non-Python tools.
"""
import uuid
spec = SandboxSpec(
name=f"py-{uuid.uuid4().hex[:8]}", image=image, **kwargs
)
sb = await self.create(spec)
try:
return await sb.exec("python", ["-c", code])
finally:
try:
await sb.stop()
except Exception: # noqa: BLE001
pass
try:
await self.remove(spec.name)
except Exception: # noqa: BLE001
pass
async def run_shell(
self,
script: str,
*,
image: str = "python:3.11-slim",
**kwargs: Any,
) -> ExecResult:
"""Convenience: spin a one-shot sandbox, run an arbitrary shell script,
tear down.
Pass ``image=`` to pick the toolchain (e.g. ``"node:20-slim"`` for
npm-based tools like codex, ``"rust:1-slim"`` for cargo,
``"alpine/git"`` for plain git ops). The default ``python:3.11-slim``
already has bash/coreutils/curl/git so most one-liners just work.
"""
import uuid
spec = SandboxSpec(
name=f"sh-{uuid.uuid4().hex[:8]}", image=image, **kwargs
)
sb = await self.create(spec)
try:
return await sb.shell(script)
finally:
try:
await sb.stop()
except Exception: # noqa: BLE001
pass
try:
await self.remove(spec.name)
except Exception: # noqa: BLE001
pass
class SandboxUnavailable(RuntimeError):
"""Raised when ``ctx.sandbox`` is accessed but no runtime is attached."""

View File

@@ -24,10 +24,12 @@ from pydantic import BaseModel
from ..agent import A2AAgent, SkillInputError, SkillNotFound
from ..auth import APIKeyAuth, NoAuth
from ..context import LocalRunContext, MissingScopes
from ..grants import Grant, GrantInvalid, verify_grant
class _InvokeIn(BaseModel):
arguments: dict[str, Any] = {}
grant: str | None = None
def build_app(agent: A2AAgent) -> FastAPI:
@@ -76,8 +78,23 @@ def build_app(agent: A2AAgent) -> FastAPI:
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
token = _check_key(authorization)
# If the caller handed us a grant, verify it and build a
# workspace bounded by its claims. Production runtimes plug in a
# real MinIO-backed WorkspaceClient here; for now we materialize
# an empty in-memory view so policy enforcement is exercised.
granted_workspace = None
grant_obj: Grant | None = None
if body.grant is not None:
try:
grant_obj = verify_grant(body.grant)
except GrantInvalid as exc:
raise HTTPException(403, f"invalid grant: {exc}") from exc
granted_workspace = _grant_to_workspace(grant_obj, agent)
ctx: LocalRunContext[Any] = LocalRunContext(
auth=_build_auth(token), task_id=f"http-{skill_name}"
auth=_build_auth(token),
task_id=f"http-{skill_name}",
workspace=granted_workspace,
)
try:
result = await agent.invoke_json(skill_name, ctx, body.arguments)
@@ -92,11 +109,43 @@ def build_app(agent: A2AAgent) -> FastAPI:
"events": [
{"kind": e.kind, "payload": e.payload} for e in ctx.events
],
"artifacts": [
{"name": name, "size_bytes": len(data)}
for name, data in (ctx.artifacts or {}).items()
],
"grant_id": grant_obj.grant_id if grant_obj else None,
}
return app
def _grant_to_workspace(grant: Grant, agent: A2AAgent) -> Any:
"""Build a :class:`WorkspaceClient` bounded by the grant.
v1 returns a :class:`LocalWorkspaceClient` whose ``access`` policy is
derived from the grant. The runtime layer (cluster service) replaces
this with a real MinIO-backed client scoped to ``grant.bucket``.
"""
from ..workspace import (
LocalWorkspaceClient,
WorkspaceAccess,
WorkspaceMode,
)
access = WorkspaceAccess.dynamic(
max_files=64,
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
require_reason=False,
deny_patterns=tuple(grant.deny_patterns),
require_human_approval=False,
)
# Empty in-memory file map; the deployed runtime substitutes a
# MinIO-backed client. See README for the cluster-side wiring.
return LocalWorkspaceClient(
files={}, access=access, bucket=grant.bucket, issuer=grant.audience
)
def serve(agent: A2AAgent, *, host: str = "0.0.0.0", port: int = 8000) -> None:
"""Run the agent's HTTP server with uvicorn (blocking)."""
import uvicorn

View File

@@ -199,6 +199,42 @@ class WorkspaceClient(ABC):
@abstractmethod
async def list_grants(self) -> list[WorkspaceGrant]: ...
async def delegate(
self,
*,
audience: str,
allow_patterns: Sequence[str] = ("**",),
deny_patterns: Sequence[str] = (),
mode: WorkspaceMode = WorkspaceMode.READ_ONLY,
outputs_prefix: str | None = None,
ttl_seconds: int = 300,
) -> str:
"""Mint a signed grant token the caller can hand to ``ctx.call``.
The default implementation requires the workspace to expose
``self.bucket`` and ``self.issuer`` — override in concrete clients
that don't fit that shape.
"""
from .grants import mint_grant
bucket = getattr(self, "bucket", None) or getattr(self, "_bucket", None)
if bucket is None:
raise NotImplementedError(
"this WorkspaceClient does not expose a bucket; override delegate()"
)
issuer = getattr(self, "issuer", "self")
_, token = mint_grant(
issuer=issuer,
audience=audience,
bucket=bucket,
mode=mode,
allow_patterns=tuple(allow_patterns),
deny_patterns=tuple(deny_patterns),
outputs_prefix=outputs_prefix,
ttl_seconds=ttl_seconds,
)
return token
# ---------------------------------------------------------------------------
# Local in-memory implementation, for dev/tests.
@@ -288,11 +324,16 @@ class LocalWorkspaceClient(WorkspaceClient):
files: dict[str, bytes],
*,
access: WorkspaceAccess,
bucket: str = "local",
issuer: str = "local",
) -> None:
self._files: dict[str, bytes] = dict(files)
self._access = access
self._grants: dict[str, WorkspaceGrant] = {}
self._counter = 0
# Expose bucket+issuer so the default WorkspaceClient.delegate() works.
self.bucket = bucket
self.issuer = issuer
def _detect(self, path: str) -> FileType:
for ext, ft in self._EXT_TO_TYPE.items():