Skip to content

Commit cdff317

Browse files
feat(website): record a website env's traffic as PCAP, served through traffic/v1 (FD-4683)
This PR's change, rebased as one commit onto #448. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5953d5a commit cdff317

25 files changed

Lines changed: 1339 additions & 28 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎packages/agentenv-protocol/examples/website/notes/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ agent-env env website put --id notes-site \
100100
Run it from the repo root. `environment_name` comes from the card, which the spec names `notes`. The stored env
101101
has a server image and no frontend image. Pass `--skip-validation` to register without deploying.
102102

103+
To record what the site sends and receives, deploy it with `--capture-traffic` (or `"capture_traffic": true` on a
104+
task's `deploy_env` step): tcpdump runs in the server container's network namespace and the gateway serves its pcap
105+
files as `traffic/v1`, which a `collect_traffic_capture` step with the env's `env_id` stores as file artifacts.
106+
103107
`env.toml` describes the same env for a bundle. Its keys are `type`, `spec` and `backend_image`; the env takes its
104108
`environment_name`, health path and domains from the spec. A bundle builds `backend_image` with this
105109
folder as the build context, but this Dockerfile needs the repo root. Until the protocol package is installed from a

‎packages/agentenv-protocol/src/agentenv_protocol/__init__.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
from .client import ExtensionCallError
55
from .errors import Applied, AgentEnvError
66
from .preflight import intake_fit_check
7+
from . import traffic
8+
from .traffic import (
9+
TRAFFIC_EXTENSION_URI,
10+
TrafficCapture,
11+
TrafficCaptureListing,
12+
fetch_traffic_capture,
13+
list_traffic_captures,
14+
)
715
from .tool_client import ToolResult, ToolSession, ToolSessionError
816
from .agent_env_environment import (
917
HIDDEN_BY_DEFAULT_META_KEY,
@@ -88,6 +96,12 @@
8896

8997
__all__ = [
9098
"client",
99+
"traffic",
100+
"TRAFFIC_EXTENSION_URI",
101+
"TrafficCapture",
102+
"TrafficCaptureListing",
103+
"list_traffic_captures",
104+
"fetch_traffic_capture",
91105
"AgentEnvApplication",
92106
"AgentEnvEnvironment",
93107
"AgentEnvFastMCPApplication",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Traffic capture, ``urn:agentenv:traffic/v1``: an env that records the packets its containers send and receive serves
2+
them as capture files through its card. ``list`` (``TRAFFIC_LIST_OP``) answers a ``TrafficCaptureListing``, each file a
3+
``TrafficCapture`` named for the container it holds, oldest first; ``get`` (``TRAFFIC_GET_OP``) streams the file a
4+
``list`` entry names, given as its ``name`` param, as ``PCAP_MEDIA_TYPE``. Both are framework-only: they take the env's
5+
control token (``client.control_headers``). ``serves_traffic`` reads whether a card advertises it,
6+
``list_traffic_captures`` and ``fetch_traffic_capture`` call it as a card advertises it."""
7+
from __future__ import annotations
8+
9+
from datetime import datetime
10+
11+
import httpx
12+
from pydantic import BaseModel, ConfigDict
13+
14+
from .client import ExtensionCallError, find_extension_method, invoke_extension, request_headers
15+
16+
TRAFFIC_EXTENSION_URI = "urn:agentenv:traffic/v1"
17+
TRAFFIC_LIST_OP = "list"
18+
TRAFFIC_GET_OP = "get"
19+
TRAFFIC_FORMAT = "pcap"
20+
PCAP_MEDIA_TYPE = "application/vnd.tcpdump.pcap"
21+
LIST_TIMEOUT_S = 30.0
22+
FETCH_TIMEOUT = httpx.Timeout(600.0, connect=10.0)
23+
24+
25+
class TrafficCapture(BaseModel):
26+
"""One capture file a ``list`` names: its ``name``, which ``get`` takes, the ``container`` whose packets it holds,
27+
its size in ``bytes`` and when it was last written. Fields it doesn't name are ignored, so a reader keeps reading
28+
an env that says more."""
29+
30+
model_config = ConfigDict(extra="ignore")
31+
32+
name: str
33+
container: str
34+
bytes: int
35+
modified_at: datetime
36+
37+
38+
class TrafficCaptureListing(BaseModel):
39+
"""``list``'s answer: the files' ``format`` and each ``TrafficCapture``, oldest first within a container, so its
40+
files read in the order their packets were captured."""
41+
42+
model_config = ConfigDict(extra="ignore")
43+
44+
format: str = TRAFFIC_FORMAT
45+
captures: list[TrafficCapture] = []
46+
47+
48+
def serves_traffic(card: dict) -> bool:
49+
"""Whether ``card`` advertises traffic/v1's ``list``; no network."""
50+
return isinstance(card, dict) and find_extension_method(card, TRAFFIC_EXTENSION_URI, TRAFFIC_LIST_OP) is not None
51+
52+
53+
async def list_traffic_captures(base_url: str, card: dict, *, timeout: float = LIST_TIMEOUT_S, verify: bool = True,
54+
headers: dict[str, str] | None = None) -> TrafficCaptureListing:
55+
"""The captures the env whose ``card`` is read from ``base_url`` serves, through its ``list``. Raises
56+
``ValueError`` when the card doesn't advertise it or the answer isn't a ``TrafficCaptureListing``, httpx's error
57+
when it can't be asked, and ``ExtensionCallError`` for a non-2xx answer."""
58+
answer = await invoke_extension(base_url, card, TRAFFIC_EXTENSION_URI, timeout=timeout, verify=verify,
59+
method=TRAFFIC_LIST_OP, headers=headers)
60+
return TrafficCaptureListing.model_validate(answer)
61+
62+
63+
async def fetch_traffic_capture(base_url: str, card: dict, name: str, path: str, *,
64+
timeout: httpx.Timeout | float = FETCH_TIMEOUT, verify: bool = True,
65+
headers: dict[str, str] | None = None) -> int:
66+
"""Stream the capture ``name``, as a ``list`` entry names it, from the env whose ``card`` is read from
67+
``base_url`` through its ``get`` to the file ``path``, and return its size. Raises ``ValueError`` when the card
68+
doesn't advertise ``get``, httpx's error when it can't be asked, and ``ExtensionCallError`` for a non-2xx answer,
69+
with nothing written."""
70+
op = find_extension_method(card, TRAFFIC_EXTENSION_URI, TRAFFIC_GET_OP)
71+
if op is None or not op["endpoint"]:
72+
raise ValueError(f"card does not advertise {TRAFFIC_EXTENSION_URI} {TRAFFIC_GET_OP}")
73+
url = f"{base_url}{op['endpoint']}"
74+
params = {"name": name}
75+
request = {"params": params} if op["method"] == "GET" else {"json": params}
76+
size = 0
77+
async with httpx.AsyncClient(verify=verify, timeout=timeout) as client:
78+
async with client.stream(op["method"], url, headers=request_headers(url, headers), **request) as response:
79+
if response.status_code >= 400:
80+
await response.aread()
81+
raise ExtensionCallError(response)
82+
with open(path, "wb") as out:
83+
async for chunk in response.aiter_bytes():
84+
out.write(chunk)
85+
size += len(chunk)
86+
return size
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""traffic/v1's client helpers: a card says whether an env serves it, ``list_traffic_captures`` reads its listing as
2+
typed captures and ``fetch_traffic_capture`` streams one to a file, each at the endpoint and verb the card advertises,
3+
with the headers given or granted."""
4+
from __future__ import annotations
5+
6+
from datetime import datetime, timezone
7+
8+
import httpx
9+
import pytest
10+
11+
from agentenv_protocol import client as protocol_v1
12+
from agentenv_protocol.traffic import (
13+
PCAP_MEDIA_TYPE,
14+
TRAFFIC_EXTENSION_URI,
15+
TrafficCapture,
16+
fetch_traffic_capture,
17+
list_traffic_captures,
18+
serves_traffic,
19+
)
20+
21+
_RealAsyncClient = httpx.AsyncClient
22+
CARD = {"capabilities": {"extensions": [{"uri": TRAFFIC_EXTENSION_URI, "params": {"methods": {
23+
"list": {"method": "GET", "endpoint": "/svc/x/traffic/captures"},
24+
"get": {"method": "GET", "endpoint": "/svc/x/traffic/capture"}}}}]}}
25+
LISTING = {"format": "pcap", "extra": 1, "captures": [
26+
{"name": "site-backend.pcap0", "container": "site-backend", "bytes": 24, "modified_at": "1970-01-01T00:16:40+00:00",
27+
"more": "ignored"}]}
28+
29+
30+
@pytest.fixture
31+
def seen(monkeypatch):
32+
requests: list[httpx.Request] = []
33+
34+
def env(request: httpx.Request) -> httpx.Response:
35+
requests.append(request)
36+
if request.headers.get("authorization") != "Bearer tok":
37+
return httpx.Response(401, json={"ok": False, "error": {"code": "unauthorized", "message": "no token"}})
38+
if request.url.path.endswith("/captures"):
39+
return httpx.Response(200, json=LISTING)
40+
return httpx.Response(200, content=b"pcap-bytes", headers={"content-type": PCAP_MEDIA_TYPE})
41+
42+
transport = httpx.MockTransport(env)
43+
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _RealAsyncClient(transport=transport, **kw))
44+
return requests
45+
46+
47+
def test_a_card_serves_traffic_only_when_it_advertises_list():
48+
assert serves_traffic(CARD)
49+
assert not serves_traffic({"capabilities": {"extensions": []}})
50+
assert not serves_traffic(None)
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_list_reads_the_advertised_endpoint_as_typed_captures(seen):
55+
listing = await list_traffic_captures("http://env", CARD, headers=protocol_v1.control_headers("tok"))
56+
assert listing.format == "pcap"
57+
assert listing.captures == [TrafficCapture(name="site-backend.pcap0", container="site-backend", bytes=24,
58+
modified_at=datetime(1970, 1, 1, 0, 16, 40, tzinfo=timezone.utc))]
59+
assert [str(request.url) for request in seen] == ["http://env/svc/x/traffic/captures"]
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_fetch_streams_the_named_capture_to_a_file_with_the_granted_headers(seen, tmp_path):
64+
path = tmp_path / "out.pcap"
65+
with protocol_v1.authorized({"http://env": protocol_v1.control_headers("tok")}):
66+
size = await fetch_traffic_capture("http://env", CARD, "site-backend.pcap0", str(path))
67+
assert (size, path.read_bytes()) == (10, b"pcap-bytes")
68+
assert str(seen[0].url) == "http://env/svc/x/traffic/capture?name=site-backend.pcap0"
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_a_refusal_raises_an_extension_call_error_and_writes_nothing(seen, tmp_path):
73+
path = tmp_path / "out.pcap"
74+
with pytest.raises(protocol_v1.ExtensionCallError) as refused:
75+
await fetch_traffic_capture("http://env", CARD, "site-backend.pcap0", str(path), headers={})
76+
with pytest.raises(protocol_v1.ExtensionCallError):
77+
await list_traffic_captures("http://env", CARD, headers={})
78+
assert refused.value.code == "unauthorized" and not path.exists()
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_a_card_without_traffic_is_refused_before_any_call(seen, tmp_path):
83+
with pytest.raises(ValueError):
84+
await list_traffic_captures("http://env", {}, headers={})
85+
with pytest.raises(ValueError):
86+
await fetch_traffic_capture("http://env", {}, "x.pcap0", str(tmp_path / "x"), headers={})
87+
assert seen == []

‎src/agent_env/cli/env/deploy.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@
5151
@click.option("--keyed-roles", is_flag=True, default=False,
5252
help="Draw a role key for the deploy: its gateway then believes a role other than default only from a "
5353
"caller that sends the key beside it.")
54-
def deploy(env_id: str, env_version: int | None, ttl_seconds: int, gateway_mode: str, sandbox: str, service_db_env_id: str | None, gateway_env_id: str | None, website_browser_env_id: str | None, disk_size_gb: float, cpu: float | None, memory_mb: int | None, env_state_type: str | None, env_state_instance_id: str | None, keyed_roles: bool):
54+
@click.option("--capture-traffic", is_flag=True, default=False,
55+
help="Record every packet the env's websites' containers send and receive, as rotating pcap files its "
56+
"gateway serves as traffic/v1 (VM deploys of website or multi envs only).")
57+
def deploy(env_id: str, env_version: int | None, ttl_seconds: int, gateway_mode: str, sandbox: str, service_db_env_id: str | None, gateway_env_id: str | None, website_browser_env_id: str | None, disk_size_gb: float, cpu: float | None, memory_mb: int | None, env_state_type: str | None, env_state_instance_id: str | None, keyed_roles: bool, capture_traffic: bool):
5558
"""Deploy an MCP server environment."""
5659
from agent_env.providers import build_sandbox_provider
5760
from agent_env.config import get_config
@@ -94,6 +97,8 @@ def deploy(env_id: str, env_version: int | None, ttl_seconds: int, gateway_mode:
9497
click.echo(f"Env state instance id: {env_state_instance_id}")
9598
if keyed_roles:
9699
deploy_kwargs["keyed_roles"] = True
100+
if capture_traffic:
101+
deploy_kwargs["capture_traffic"] = True
97102
try:
98103
with run_scope(env.id):
99104
deployed_env = asyncio.run(env.deploy(**deploy_kwargs))

‎src/agent_env/env/envs/_deployment.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,13 +288,19 @@ def provider_or_class(env: Env) -> EnvironmentProvider | type[EnvironmentProvide
288288

289289
def deploy_refusal(env: Env, provider: EnvironmentProvider | type[EnvironmentProvider], options: dict) -> str | None:
290290
"""Why deploying env through provider, or a provider of that class, is refused before anything is built, or None: the provider
291-
can't deploy an env like it (see ``provider_refusal``), or an option it can't take."""
291+
can't deploy an env like it (see ``provider_refusal``), ``capture_traffic`` for an env that serves no website, or an option it
292+
can't take."""
293+
from agent_env.env.envs.website import WebsiteEnv
294+
292295
provider_class = provider if isinstance(provider, type) else type(provider)
293296
reason = provider_refusal(provider_class, type(env),
294297
mcp_names=[child.environment_name for child in getattr(env, "mcp_server_envs", ())],
295298
website_names=[child.environment_name for child in getattr(env, "website_envs", ())])
296299
if reason is not None:
297300
return f"env '{env.id}' has env_provider_type '{env.env_provider_type}', which {reason}"
301+
if options.get("capture_traffic") and not (isinstance(env, WebsiteEnv) or getattr(env, "website_envs", ())):
302+
return (f"env '{env.id}' serves no website, and capture_traffic records what a website's containers send and "
303+
"receive")
298304
return option_refusal(env, provider, options)
299305

300306

‎src/agent_env/env/envs/multi_env.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,13 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict:
157157
ctx.refuse(problems)
158158
return fields
159159

160-
async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False) -> DeployedEnv:
160+
async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False, capture_traffic: bool = False) -> DeployedEnv:
161161
deployed_env = await deploy_through_provider(
162162
self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type,
163163
disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb,
164164
env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution,
165165
artifact_id=artifact_id, artifact_version=artifact_version, **({"keyed_roles": True} if keyed_roles else {}),
166+
**({"capture_traffic": True} if capture_traffic else {}),
166167
)
167168
self._gateway_mode = gateway_mode
168169
self._mcp_server_name = deployed_env.mcp_server_name

‎src/agent_env/env/envs/website.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,13 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict:
210210
fields["domains"] = spec.site.domains
211211
return fields
212212

213-
async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False) -> DeployedEnv:
213+
async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, artifact_id: str | None = None, artifact_version: int | None = None, *, attribution: Optional[Attribution] = None, keyed_roles: bool = False, capture_traffic: bool = False) -> DeployedEnv:
214214
return await deploy_through_provider(
215215
self, environment_name=self.environment_name, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type,
216216
disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb,
217217
env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution,
218218
artifact_id=artifact_id, artifact_version=artifact_version, **({"keyed_roles": True} if keyed_roles else {}),
219+
**({"capture_traffic": True} if capture_traffic else {}),
219220
)
220221

221222
def deploy_refusal(self, *, sandbox_type: str | None = None, **options) -> str | None:

‎src/agent_env/env/gateway/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
FROM public.ecr.aws/docker/library/python:3.11-slim@sha256:c20888b6acdd1e63e1c433a185bf3ad162c0288fe484616ce062e0d28add2900
22

3-
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
3+
RUN apt-get update && apt-get install -y curl tcpdump && rm -rf /var/lib/apt/lists/*
44

55
WORKDIR /app
66

0 commit comments

Comments
 (0)