Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,8 @@ are the profile's template. The base class adds:
`notifications`, `deny` (the default), which answers a page's prompt for it, or `ask`; and `dialogs`, who
answers a page's JavaScript dialogs: `agent` (the default), or agentenv, accepting (`accept`, a `prompt()`
with its default text) or dismissing (`dismiss`) each. Either way agentenv leaves a page that asks before
it's left (`beforeunload`), so none of its answers blocks a navigation.
it's left (`beforeunload`), so none of its answers blocks a navigation. And `network`, on by default, whether
`data/get` exports its network log and `browser/v1` `network` reads it.
- **The flags agentenv owns**, after the subclass's command and before `about:blank`, the one tab its command line
opens: its profile, its
HTTP cache under `~/.cache` (`--disk-cache-dir`), `--remote-debugging-pipe`, `--no-first-run`,
Expand Down Expand Up @@ -1032,10 +1033,27 @@ are the profile's template. The base class adds:
- A driver passes an agent's tool call to the browser in the `AgentEnv-Tool-Call` header (`tool_call_header`), with
the caller's `AgentEnv-Role`; the page driver sends it on every call it makes for a tool. `data/get` writes the log
beside `browser-state.json` as `browser-log.jsonl`, which `data/add` passes over.
- **Its network log** is the one the proxy it goes out through keeps, in the file `AGENTENV_NETWORK_LOG` names
(`website_browser.network`): on a browser alone routing sites, the shared site proxy's,
`/etc/agentenv/sites/network.jsonl`, which root keeps and the session user reads; else the base's stand-in's,
`/var/log/agentenv/network.jsonl`. One JSON object a line, numbered (`seq`) and timed (`at`) as it's written, a
proxy started again numbering after the file's last: an `open` line once a request has gone upstream or a tunnel
has opened, and an `end` line, naming the same `id`, once it's answered and done or has failed. The shared site
proxy keeps each exchange whole: the request as the browser sent it, its headers, the values of the sign-in headers
a site declares as `<removed>`, and its body up to 10 MB; the answer as the browser got it, its status, its headers
and, for text, JSON, forms, XML and scripts, its body up to 1 MB, as it came, in base64, any other type's size and
sha256 alone; a WebSocket's messages and bytes each way; and each tunnel to a live host, refusal and failure. The
stand-in, which decrypts nothing, keeps each connection: its host, port, address and bytes each way, and a plain
request's method and URL. Unless its `network` setting is off, `data/get` writes it as HAR 1.2, `network.har`,
which `data/add` passes over: an entry
for each request and tunnel in the order they started, one still going marked `_open`, bodies decoded from gzip and
deflate, and br and zstd with the `app` extra's `brotli` and `zstandard`, up to 1 MB, and agentenv's own fields
named with `_`: `_tunnel`, `_websocket`, `_failed`, `_refused`, `_truncated`, `_sha256`.
- **`browser/v1`** on its card: `chromium` (its `version`), `clock: "real"` and, alone, `window`; `log {after}`
(GET), framework-only, its log's lines after `after`; `calls/begin {user, acts?, within_s?}`, framework-only,
sent with the tool-call header, which begins the entry of an agent's tool call that `user`'s CDP connections
serve and answers its `entry`, and `calls/end {entry, ok, error?}`, which ends it, a call not ended within
(GET), framework-only, its log's lines after `after`; `network {after}` (GET), framework-only, its network log's
lines after `after`, 500 at most and `more` when a read left some; `calls/begin {user, acts?, within_s?}`,
framework-only, sent with the tool-call header, which begins the entry of an agent's tool call that `user`'s CDP
connections serve and answers its `entry`, and `calls/end {entry, ok, error?}`, which ends it, a call not ended within
`within_s` (600 s by default) written as never said to have ended; and
`record` (GET), framework-only, which returns that record with `launches`, the app server's log of each
launch (with its cause: `boot`, `server_restart`, `launch`, `data/add` or `data/reset`), each stop it made
Expand Down
4 changes: 4 additions & 0 deletions packages/agentenv-protocol/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ agent = [
# A computer app's own server: ComputerAppEnvironment serves its card and computer-app/v1 over mcp's FastMCP, on a Unix
# socket through uvicorn, and a website browser's CDP relay over WebSockets.
app = [
"brotli>=1.2", # a website browser's network log: br answer bodies, decoded for network.har
"mcp>=1.25,<2",
"uvicorn>=0.30",
"websockets>=13",
"zstandard>=0.23", # and zstd ones
]
# A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP, crops and encodes frames, records
# the display as H.264 with PyAV (computer-recording/v1), and relays WebSockets to its apps on /svc/<app>/.
Expand Down Expand Up @@ -76,6 +78,8 @@ dev = [
"Pillow>=10.4", # the computer extra's frame crops and encodings
"av>=17", # the computer extra's encoder, and the tests' decoder
"websockets>=13", # the computer extra's /svc/<app>/ relay
"brotli>=1.2", # the app extra's network.har decoding
"zstandard>=0.23",
]
[project.urls]
Homepage = "https://www.agentenvframework.com"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@
from .environment import (ALONE_BYPASS, BLOCK, BROWSER, COMPUTER_BYPASS, OWNED_FLAGS, POLICIES, READY_POLL_S,
SHM_MIN_BYTES, SITE_PROXY_ENV, SITES, SKILL, BrowserSettings, WebsiteBrowserEnvironment,
Window, start_tab_why)
from .network import DEFAULT_NETWORK_LOG, HAR_FILE, NETWORK_LOG_ENV, NetworkLog, har
from .shapes import (BrowserRecord, ClosedTab, Cookie, CookieToSet, Dialog, Download, LaunchEntry, OpenTab,
OriginStorage, PartitionKey, SiteNames, StartTab, StartTabsLaunch, StorageItem, origin_of)

__all__ = ["ACTIVE_CHECK_TIMEOUT_S", "AGENT", "AGENTENV", "ALONE_BYPASS", "BLOCK", "BROWSER", "CAPTURE_TIMEOUT_S",
"COMPUTER_BYPASS", "DOWNLOADS", "EXIT", "FAILED", "HASH_CHUNK_BYTES", "LIVE_START_TAB_TIMEOUT_S", "LOADED",
"MAX_MESSAGE_BYTES", "OWNED_FLAGS", "PAGE_INPUT", "PAGES", "PIN_TIMEOUT_S", "PIPE_IN", "PIPE_OUT",
"COMPUTER_BYPASS", "DEFAULT_NETWORK_LOG", "DOWNLOADS", "EXIT", "FAILED", "HAR_FILE", "HASH_CHUNK_BYTES",
"LIVE_START_TAB_TIMEOUT_S", "LOADED", "MAX_MESSAGE_BYTES", "NETWORK_LOG_ENV", "OWNED_FLAGS", "PAGE_INPUT",
"PAGES", "PIN_TIMEOUT_S", "PIPE_IN", "PIPE_OUT",
"POLICIES", "READY_POLL_S", "SHM_MIN_BYTES", "SITE_PROXY_ENV", "SITES", "SKILL", "STOP", "TIMED_OUT",
"UNKNOWN", "UNNAVIGATED_CAPTURE_TIMEOUT_S", "UPLOADS", "BrowserControl", "BrowserSettings", "DevToolsError",
"DevToolsPipe", "WebsiteBrowserEnvironment", "Window", "BrowserRecord", "ClosedTab", "Cookie", "CookieToSet",
"Dialog", "Download", "LaunchEntry", "OpenTab", "OriginStorage", "PartitionKey", "SiteNames", "StartTab",
"StartTabsLaunch", "StorageItem", "origin_of", "start_tab_why"]
"DevToolsPipe", "NetworkLog", "WebsiteBrowserEnvironment", "Window", "BrowserRecord", "ClosedTab", "Cookie",
"CookieToSet", "Dialog", "Download", "LaunchEntry", "OpenTab", "OriginStorage", "PartitionKey", "SiteNames",
"StartTab",
"StartTabsLaunch", "StorageItem", "har", "origin_of", "start_tab_why"]
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ..computer.dataplane import part_bytes
from ..types import DataPart, FilePart, uploaded_object_path
from .control import DOWNLOADS, UPLOADS
from .network import HAR_FILE

#: The browser's typed state, as ``data/get`` returns it beside its files and ``data/add`` reads it back.
STATE_FILE = "browser-state.json"
Expand Down Expand Up @@ -54,7 +55,7 @@ async def sorted_load(parts: list, profile: str) -> Load:
under = PurePosixPath(path)
if under in (PurePosixPath(profile), PurePosixPath(DOWNLOADS), PurePosixPath(UPLOADS)):
raise ValueError(f"{path!r} is a folder of the browser's, not a file in it")
if path == LOG_FILE:
if path in (LOG_FILE, HAR_FILE):
continue
if path == STATE_FILE:
if load.state is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .cdp import ROUTE, Relay, Tokens, reached, under, websocket_form
from .log import (ACTOR, ERROR_CHARS, TOOL_CALL_HEADER, UNLOGGED_OPS, BrowserLog, op_acts, read_tool_call,
redact_text, redacted_args, redacted_op)
from .network import HAR_FILE, NetworkLog, log_path
from ..computer import AppDeclaration, ComputerAppEnvironment, Launch, install_step, ready_check
from ..computer import app as computer_app
from ..computer import app_data
Expand Down Expand Up @@ -106,6 +107,9 @@ class BrowserSettings(BaseModel):
notifications: Literal["deny", "ask"] = "deny"
#: Who answers a page's JavaScript dialogs: the agent, or agentenv accepting or dismissing each.
dialogs: Literal["agent", "accept", "dismiss"] = "agent"
#: Whether ``data/get`` exports its network log, ``network.har``, and ``browser/v1`` ``network`` reads it: off, a
#: run's export holds none of the cookies and form bodies the log keeps.
network: bool = True

@field_validator("start_urls")
@classmethod
Expand Down Expand Up @@ -151,6 +155,8 @@ def __init__(self) -> None:
self.log = BrowserLog.for_app(os.environ.get("ENVIRONMENT_NAME") or type(self).__name__.lower(),
SOCKET.parent)
self.control.log = self.log
#: Its network log, as the proxy it goes out through writes it.
self.network = NetworkLog(log_path())
#: The CDP addresses it has handed out, and the relay agents' connections to them go through.
self.cdp_tokens = Tokens(SOCKET.parent / "cdp-tokens.json")
self.relay = Relay(self.devtools, dialogs_answered=self.settings.dialogs != AGENT, log=self.log,
Expand Down Expand Up @@ -331,7 +337,13 @@ async def start_tabs_loaded(self) -> bool:
"the env's site and the outcome, loaded, failed or timed_out. log returns its log's lines after the sequence "
"number after, in order: an action entry for each CDP command, MCP tool call and pixel action an agent took, "
"marked acting or reading, with who, the tab and its URL and, acting, its parameters with secrets taken out; "
"and a tab event each time a tab opened, navigated or closed, with what caused it."))
"and a tab event each time a tab opened, navigated or closed, with what caused it. network returns its "
"network log's lines after the sequence number after, in order, as the proxy it goes out through writes them: "
"an open line once a request has gone upstream or a tunnel has opened and an end line, with the same id, once "
"it's done or has failed. The shared site proxy writes a whole exchange, the request and the answer with their "
"headers and bodies, the sign-in headers a site declares removed, and a WebSocket's message counts; the base's "
"stand-in writes each connection, its host, address and bytes each way. With the network setting off, network "
"returns no lines."))
async def browser_record(self) -> dict:
return BrowserRecord(**await self.control.record(), launches=self._computer_app.launches(),
cdp_connections=list(self.relay.connections),
Expand All @@ -341,6 +353,12 @@ async def browser_record(self) -> dict:
async def browser_log(self, after: int = 0) -> dict:
return await self.log.read_async(after)

@extension(BROWSER, op="network", method="GET")
async def browser_network(self, after: int = 0) -> dict:
if not self.settings.network:
return {"lines": [], "last": after, "more": False}
return await self.network.read_async(after)

@extension(BROWSER, op="sites/set")
async def browser_sites_set(self, sites: list[dict]) -> dict:
"""The env's own sites, each by its name and domains, which it keeps for each launch after: a start tab on one
Expand Down Expand Up @@ -538,23 +556,27 @@ def _resets_failed_add(self) -> bool:

def declared_extensions(self) -> list[EnvironmentExtension]:
"""Its data plane moves files by their path, a file tree: its state as ``browser-state.json``, its log, its
profile, its downloads and files for agents to upload. A load takes its state as a data part too."""
network log as HAR, its profile, its downloads and files for agents to upload. A load takes its state as a data
part too."""
tree = IntakeDeclaration(get=[IntakeFormat(part="file", format="file-tree")],
add=[IntakeFormat(part="file", format="file-tree", load="replace"),
IntakeFormat(part="data", format="json", load="replace")])
return [*super().declared_extensions(), intake_extension(tree, (
f"data/get returns the browser's state as {data.STATE_FILE}: its cookies and local storage as Playwright's "
f"storageState has them, its tabs open and closed, its downloads and its clock; beside it, its log as "
f"{data.LOG_FILE}, which data/add passes over, its profile "
f"{data.LOG_FILE} and, unless its network setting is off, its network log as HAR 1.2 in {HAR_FILE}, an "
f"entry for each request and tunnel in the order they started, both of which data/add passes over, its "
f"profile "
f"without caches, and its finished downloads. data/add applies such a state, while the browser runs, and "
f"writes files by their path: its profile, restored with the browser stopped, ~/{DOWNLOADS} and "
f"~/{UPLOADS}. data/reset puts the profile back as installed and relaunches it at its start tabs."))]

@get_data
async def browser_get_data(self, write_namespace: Optional[WriteNamespaceGrant] = None) -> list:
"""Its state, as ``browser-state.json``, its profile without caches, copied with the browser paused so its files
are of one moment, and its finished downloads: each a file uploaded under ``write_namespace`` when there is
one, else inline."""
"""Its state, as ``browser-state.json``, its log, its network log as HAR unless its ``network`` setting is
off, its profile without caches, copied
with the browser paused so its files are of one moment, and its finished downloads: each a file uploaded under
``write_namespace`` when there is one, else inline."""
home = Path.home()
state = BrowserState(cookies=await self.control.cookies(), local_storage=await self.control.local_storage(),
tabs=await self.control.tab_list(),
Expand All @@ -567,9 +589,13 @@ async def browser_get_data(self, write_namespace: Optional[WriteNamespaceGrant]
root = Path(staging)
(root / data.STATE_FILE).write_text(json.dumps(written, indent=1), encoding="utf-8")
(root / data.LOG_FILE).write_text(await self.log.complete_text(), encoding="utf-8")
exported = [data.STATE_FILE, data.LOG_FILE]
if self.settings.network:
(root / HAR_FILE).write_text(await self.network.har_text(), encoding="utf-8")
exported.append(HAR_FILE)
with self._computer_app.paused():
await app_data.in_thread(data.stage_profile, home, self.profile, root)
parts = await file_parts(root, [data.STATE_FILE, data.LOG_FILE, self.profile], write_namespace)
parts = await file_parts(root, [*exported, self.profile], write_namespace)
return parts + await file_parts(home, data.finished_downloads(home), write_namespace)

@add_data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@
ACTING_OPS = frozenset({"page/input", "navigate", "history", "tabs/open", "tabs/activate", "tabs/close",
"dialog/answer", "files/choose", "cookies/set", "cookies/delete", "local_storage/set",
"local_storage/clear", "sites/set"})
#: The ``browser/v1`` ops an agent's calls are never logged for: the log itself, and agentenv's own bookkeeping, a
#: driver's calls that begin and end a tool call's own entry among them.
UNLOGGED_OPS = frozenset({"log", "record", "calls/begin", "calls/end"})
#: The ``browser/v1`` ops an agent's calls are never logged for: the logs themselves, and agentenv's own bookkeeping,
#: a driver's calls that begin and end a tool call's own entry among them.
UNLOGGED_OPS = frozenset({"log", "network", "record", "calls/begin", "calls/end"})
#: The CDP methods and ops whose text is typed into the page.
TYPING_METHODS = frozenset({"Input.insertText", "Input.dispatchKeyEvent", "Input.imeSetComposition"})
#: What a key event says of the key it types, all of it dropped when a password field has focus.
Expand Down
Loading