agentenv-framework-protocol 0.1.451__tar.gz → 0.1.454__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/PKG-INFO +41 -2
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/README.md +39 -1
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/pyproject.toml +3 -2
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/agent_env_environment.py +33 -8
- agentenv_framework_protocol-0.1.454/src/agentenv_protocol/browser_cdp.py +339 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/app.py +4 -1
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/apps.py +6 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/driver.py +27 -5
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/svc.py +40 -14
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/website_browser.py +91 -9
- agentenv_framework_protocol-0.1.454/tests/test_browser_cdp.py +352 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_recording.py +16 -3
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_svc_forward.py +40 -6
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/_declare.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/_linux.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/_load.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/_with_fds.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/app_data.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/app_runtime.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/card.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/checks.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/conformance/client.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/conformance/report.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/errors.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/frames.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/keys.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/launch.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/operations.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/platform.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/queue.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/recording.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/results.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/run_install_step.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/runtime.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/seed.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/serve.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/serve_app.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/session.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/settings.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/skill.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/spaces.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/computer/tools.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/errors.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/computer_fake.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/probe_fake.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_a2a_cdp_config.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_a2a_peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_a2a_proxy.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_a2a_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_app_data.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_app_server.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_conformance.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_conformance_checks.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_conformance_page.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_conformance_recording.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_declare.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_driver.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_extensions.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_frames.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_page.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_queue.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_seed.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_serve.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_computer_spaces.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_control_token.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_data_objects.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_errors.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_extension_methods.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_preflight.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_tool_annotations.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_tool_context_fastmcp.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_tool_context_injection.py +0 -0
- {agentenv_framework_protocol-0.1.451 → agentenv_framework_protocol-0.1.454}/tests/test_website_browser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentenv-framework-protocol
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.454
|
|
4
4
|
Summary: Open data-plane protocol and server SDK for agent environments.
|
|
5
5
|
Project-URL: Homepage, https://www.agentenvframework.com
|
|
6
6
|
Project-URL: Documentation, https://www.agentenvframework.com/docs
|
|
@@ -37,6 +37,7 @@ Requires-Dist: uvicorn>=0.30; extra == 'agent'
|
|
|
37
37
|
Provides-Extra: app
|
|
38
38
|
Requires-Dist: mcp<2,>=1.25; extra == 'app'
|
|
39
39
|
Requires-Dist: uvicorn>=0.30; extra == 'app'
|
|
40
|
+
Requires-Dist: websockets>=13; extra == 'app'
|
|
40
41
|
Provides-Extra: computer
|
|
41
42
|
Requires-Dist: av>=17; extra == 'computer'
|
|
42
43
|
Requires-Dist: mcp<2,>=1.25; extra == 'computer'
|
|
@@ -208,7 +209,8 @@ app without one, so a server that mounted is a server whose tool calls are bound
|
|
|
208
209
|
To answer a call before the tool runs, override `on_tool_call(self, context)`: return `None` to let
|
|
209
210
|
it proceed, or an `mcp.types.CallToolResult` (`isError=True` for a refusal) to send that as the
|
|
210
211
|
tool's reply. An `@extension` method may declare the same parameter; it is filled from the request's
|
|
211
|
-
`AgentEnv-Role` header.
|
|
212
|
+
`AgentEnv-Role` header. It may also declare a parameter annotated `starlette.requests.Request`, which
|
|
213
|
+
gets the HTTP request itself, its headers say, and stays out of the advertised schema. Code that acts as a caller outside any dispatch, such as a test calling a
|
|
212
214
|
handler directly or a seeding script, binds a context it builds:
|
|
213
215
|
`with bound(ToolContext.as_caller("alice")): ...`.
|
|
214
216
|
|
|
@@ -839,6 +841,11 @@ requests to the app's server, taking the control token:
|
|
|
839
841
|
closes, the other then closed with its code; refused before they open without the token, or with no
|
|
840
842
|
such app or one not answering. The `computer` extra brings `websockets` for this.
|
|
841
843
|
|
|
844
|
+
An app's `agent_routes` (`/cdp/` for a website browser) are path prefixes of its own server that
|
|
845
|
+
agents reach this way without the control token: the driver reads them from the app's card
|
|
846
|
+
(`computer-app/v1`'s `agent_routes`) and lets a call under one through, while the app checks it
|
|
847
|
+
itself. A path with a `.` or `..` segment, escaped or not, never counts as under one.
|
|
848
|
+
|
|
842
849
|
`MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
|
|
843
850
|
env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
|
|
844
851
|
JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed.
|
|
@@ -918,6 +925,38 @@ are the profile's template. The base class adds:
|
|
|
918
925
|
- `files/choose {paths}`, files in `~/Uploads` for the file chooser open on it, which headless, and so
|
|
919
926
|
alone, it intercepts;
|
|
920
927
|
- `page/state`, the active tab's id, URL and title and the dialog or file chooser open on it.
|
|
928
|
+
- `cdp {user}`, `user`'s CDP address once the browser is ready, `http_url` to discover from and
|
|
929
|
+
`ws_url` to connect to, both at the address the call reached it at with a token agentenv mints in
|
|
930
|
+
the path: the same one each time it's asked for. Through the driver they're under
|
|
931
|
+
`/svc/<app>/cdp/<token>/`. `user` is a name the caller chooses, an agent's `deploy_agent` step id.
|
|
932
|
+
The server keeps each token in memory and only its SHA-256, with its user and base address, in
|
|
933
|
+
`.agentenv/cdp-tokens.json`, which code in the computer's session can read but not use; an address
|
|
934
|
+
outlives a relaunch of the browser and of the server, and the computer's reset ends it.
|
|
935
|
+
- **Its CDP relay**, at those addresses: `json/version` and `json/list` (also with a trailing slash,
|
|
936
|
+
and `json`) answered from the browser, since a browser on a pipe has none, with its WebSocket at the
|
|
937
|
+
address the client reached it at; and `devtools/browser`, where each WebSocket connection gets a
|
|
938
|
+
browser-level session of its own over the pipe (`Target.attachToBrowserTarget`), with its answers
|
|
939
|
+
and the events of its sessions and those it attaches, and no one else's. Only flattened sessions are
|
|
940
|
+
served, as Playwright, Puppeteer and Browser Use use. A token agentenv didn't mint is refused, and
|
|
941
|
+
when the browser goes, each connection is closed with 1012 so its client can connect again.
|
|
942
|
+
A guard sits on every command, a guardrail, since the sandbox's network is the boundary:
|
|
943
|
+
- `Browser.close`, `Browser.crash` and `Browser.crashGpuProcess` are answered `{}` and end only that
|
|
944
|
+
connection, with 1000;
|
|
945
|
+
- `Target.exposeDevToolsProtocol`, `Target.sendMessageToTarget` (only flattened sessions are
|
|
946
|
+
served, whose commands the guard sees), a `Target.createBrowserContext` with its own proxy, and a
|
|
947
|
+
`Page.navigate` or `Target.createTarget` to the browser's own pages (`file:`, `chrome:`,
|
|
948
|
+
`chrome-extension:`, `chrome-untrusted:`, `view-source:`, `devtools:`, and `about:` but
|
|
949
|
+
`about:blank` and `about:srcdoc`) get a CDP error, `agentenv refuses …`;
|
|
950
|
+
- `Browser.setDownloadBehavior` and `Page.setDownloadBehavior` are sent on as agentenv's own, the
|
|
951
|
+
browser's default into `~/Downloads`, keeping only the context and whether to send events;
|
|
952
|
+
- when the deploy answers dialogs, an agent's `Page.handleJavaScriptDialog` is answered `{}` and not
|
|
953
|
+
sent on;
|
|
954
|
+
- a message that isn't a command, an object with a string `method`, and object `params` and a string
|
|
955
|
+
`sessionId` if any, gets a CDP error with code -32600.
|
|
956
|
+
|
|
957
|
+
`record` lists each connection as `cdp_connections`: its user, when it opened and when it closed,
|
|
958
|
+
and the first 100 commands the guard refused, changed, answered or closed it on.
|
|
959
|
+
Puppeteer connects by `ws_url`: its `browserURL` drops the address's path.
|
|
921
960
|
- **Its skill**: sites by their real names, files to upload in `~/Uploads` and downloads in
|
|
922
961
|
`~/Downloads`, folders it makes.
|
|
923
962
|
|
|
@@ -150,7 +150,8 @@ app without one, so a server that mounted is a server whose tool calls are bound
|
|
|
150
150
|
To answer a call before the tool runs, override `on_tool_call(self, context)`: return `None` to let
|
|
151
151
|
it proceed, or an `mcp.types.CallToolResult` (`isError=True` for a refusal) to send that as the
|
|
152
152
|
tool's reply. An `@extension` method may declare the same parameter; it is filled from the request's
|
|
153
|
-
`AgentEnv-Role` header.
|
|
153
|
+
`AgentEnv-Role` header. It may also declare a parameter annotated `starlette.requests.Request`, which
|
|
154
|
+
gets the HTTP request itself, its headers say, and stays out of the advertised schema. Code that acts as a caller outside any dispatch, such as a test calling a
|
|
154
155
|
handler directly or a seeding script, binds a context it builds:
|
|
155
156
|
`with bound(ToolContext.as_caller("alice")): ...`.
|
|
156
157
|
|
|
@@ -781,6 +782,11 @@ requests to the app's server, taking the control token:
|
|
|
781
782
|
closes, the other then closed with its code; refused before they open without the token, or with no
|
|
782
783
|
such app or one not answering. The `computer` extra brings `websockets` for this.
|
|
783
784
|
|
|
785
|
+
An app's `agent_routes` (`/cdp/` for a website browser) are path prefixes of its own server that
|
|
786
|
+
agents reach this way without the control token: the driver reads them from the app's card
|
|
787
|
+
(`computer-app/v1`'s `agent_routes`) and lets a call under one through, while the app checks it
|
|
788
|
+
itself. A path with a `.` or `..` segment, escaped or not, never counts as under one.
|
|
789
|
+
|
|
784
790
|
`MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
|
|
785
791
|
env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
|
|
786
792
|
JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed.
|
|
@@ -860,6 +866,38 @@ are the profile's template. The base class adds:
|
|
|
860
866
|
- `files/choose {paths}`, files in `~/Uploads` for the file chooser open on it, which headless, and so
|
|
861
867
|
alone, it intercepts;
|
|
862
868
|
- `page/state`, the active tab's id, URL and title and the dialog or file chooser open on it.
|
|
869
|
+
- `cdp {user}`, `user`'s CDP address once the browser is ready, `http_url` to discover from and
|
|
870
|
+
`ws_url` to connect to, both at the address the call reached it at with a token agentenv mints in
|
|
871
|
+
the path: the same one each time it's asked for. Through the driver they're under
|
|
872
|
+
`/svc/<app>/cdp/<token>/`. `user` is a name the caller chooses, an agent's `deploy_agent` step id.
|
|
873
|
+
The server keeps each token in memory and only its SHA-256, with its user and base address, in
|
|
874
|
+
`.agentenv/cdp-tokens.json`, which code in the computer's session can read but not use; an address
|
|
875
|
+
outlives a relaunch of the browser and of the server, and the computer's reset ends it.
|
|
876
|
+
- **Its CDP relay**, at those addresses: `json/version` and `json/list` (also with a trailing slash,
|
|
877
|
+
and `json`) answered from the browser, since a browser on a pipe has none, with its WebSocket at the
|
|
878
|
+
address the client reached it at; and `devtools/browser`, where each WebSocket connection gets a
|
|
879
|
+
browser-level session of its own over the pipe (`Target.attachToBrowserTarget`), with its answers
|
|
880
|
+
and the events of its sessions and those it attaches, and no one else's. Only flattened sessions are
|
|
881
|
+
served, as Playwright, Puppeteer and Browser Use use. A token agentenv didn't mint is refused, and
|
|
882
|
+
when the browser goes, each connection is closed with 1012 so its client can connect again.
|
|
883
|
+
A guard sits on every command, a guardrail, since the sandbox's network is the boundary:
|
|
884
|
+
- `Browser.close`, `Browser.crash` and `Browser.crashGpuProcess` are answered `{}` and end only that
|
|
885
|
+
connection, with 1000;
|
|
886
|
+
- `Target.exposeDevToolsProtocol`, `Target.sendMessageToTarget` (only flattened sessions are
|
|
887
|
+
served, whose commands the guard sees), a `Target.createBrowserContext` with its own proxy, and a
|
|
888
|
+
`Page.navigate` or `Target.createTarget` to the browser's own pages (`file:`, `chrome:`,
|
|
889
|
+
`chrome-extension:`, `chrome-untrusted:`, `view-source:`, `devtools:`, and `about:` but
|
|
890
|
+
`about:blank` and `about:srcdoc`) get a CDP error, `agentenv refuses …`;
|
|
891
|
+
- `Browser.setDownloadBehavior` and `Page.setDownloadBehavior` are sent on as agentenv's own, the
|
|
892
|
+
browser's default into `~/Downloads`, keeping only the context and whether to send events;
|
|
893
|
+
- when the deploy answers dialogs, an agent's `Page.handleJavaScriptDialog` is answered `{}` and not
|
|
894
|
+
sent on;
|
|
895
|
+
- a message that isn't a command, an object with a string `method`, and object `params` and a string
|
|
896
|
+
`sessionId` if any, gets a CDP error with code -32600.
|
|
897
|
+
|
|
898
|
+
`record` lists each connection as `cdp_connections`: its user, when it opened and when it closed,
|
|
899
|
+
and the first 100 commands the guard refused, changed, answered or closed it on.
|
|
900
|
+
Puppeteer connects by `ws_url`: its `browserURL` drops the address's path.
|
|
863
901
|
- **Its skill**: sites by their real names, files to upload in `~/Uploads` and downloads in
|
|
864
902
|
`~/Downloads`, folders it makes.
|
|
865
903
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agentenv-framework-protocol"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.454"
|
|
4
4
|
description = "Open data-plane protocol and server SDK for agent environments."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -36,10 +36,11 @@ agent = [
|
|
|
36
36
|
"uvicorn>=0.30",
|
|
37
37
|
]
|
|
38
38
|
# A computer app's own server: ComputerAppEnvironment serves its card and computer-app/v1 over mcp's FastMCP, on a Unix
|
|
39
|
-
# socket through uvicorn.
|
|
39
|
+
# socket through uvicorn, and a website browser's CDP relay over WebSockets.
|
|
40
40
|
app = [
|
|
41
41
|
"mcp>=1.25,<2",
|
|
42
42
|
"uvicorn>=0.30",
|
|
43
|
+
"websockets>=13",
|
|
43
44
|
]
|
|
44
45
|
# A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP, crops and encodes frames, records
|
|
45
46
|
# the display as H.264 with PyAV (computer-recording/v1), and relays WebSockets to its apps on /svc/<app>/.
|
|
@@ -22,7 +22,7 @@ from pydantic import TypeAdapter, ValidationError
|
|
|
22
22
|
from pydantic.fields import FieldInfo
|
|
23
23
|
from starlette.requests import Request
|
|
24
24
|
from starlette.responses import Response
|
|
25
|
-
from starlette.routing import Route
|
|
25
|
+
from starlette.routing import Route, WebSocketRoute
|
|
26
26
|
|
|
27
27
|
from .errors import AgentEnvError
|
|
28
28
|
from .tool_access import DISABLE as TOOL_DISABLE, ENABLE as TOOL_ENABLE, PATHS as TOOL_ACCESS_PATHS, ToolAccess, caller_role
|
|
@@ -797,6 +797,19 @@ def _is_tool_context_annotation(annotation: Any) -> bool:
|
|
|
797
797
|
return any(t is ToolContext for t in _unwrap_annotation(annotation))
|
|
798
798
|
|
|
799
799
|
|
|
800
|
+
def _is_request_annotation(annotation: Any) -> bool:
|
|
801
|
+
if isinstance(annotation, str):
|
|
802
|
+
return Request.__name__ in _string_annotation_names(annotation)
|
|
803
|
+
return any(t is Request for t in _unwrap_annotation(annotation))
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _request_slot(fn: Callable) -> str | None:
|
|
807
|
+
"""Name of an extension handler's parameter annotated ``Request``, which receives the HTTP request, or None."""
|
|
808
|
+
hints = _resolved_hints(fn)
|
|
809
|
+
return next((name for name, param in inspect.signature(fn).parameters.items() if name not in ("self", "cls")
|
|
810
|
+
and _is_request_annotation(hints.get(name, param.annotation))), None)
|
|
811
|
+
|
|
812
|
+
|
|
800
813
|
def _fastmcp_context_type() -> type | None:
|
|
801
814
|
"""FastMCP's Context class when mcp is loaded, else None. mcp is an optional dependency, so it is
|
|
802
815
|
looked up rather than imported; a Context-annotated handler has necessarily imported it already."""
|
|
@@ -1034,7 +1047,8 @@ def _schema_from_signature(fn: Callable) -> dict:
|
|
|
1034
1047
|
if name in ("self", "cls") or param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
1035
1048
|
continue
|
|
1036
1049
|
annotation = hints.get(name, param.annotation)
|
|
1037
|
-
if _is_tool_context_annotation(annotation) or _is_fastmcp_context_annotation(annotation)
|
|
1050
|
+
if _is_tool_context_annotation(annotation) or _is_fastmcp_context_annotation(annotation) or \
|
|
1051
|
+
_is_request_annotation(annotation):
|
|
1038
1052
|
continue
|
|
1039
1053
|
prop = _schema_for_annotation(annotation)
|
|
1040
1054
|
if param.default is inspect.Parameter.empty:
|
|
@@ -1505,12 +1519,12 @@ def _coerce_params(fn: Callable, params: dict) -> dict:
|
|
|
1505
1519
|
|
|
1506
1520
|
def _extension_handler(fn: Callable, http_method: str) -> Callable:
|
|
1507
1521
|
"""REST handler for an @extension route: bind request params to the handler and return JSON. A
|
|
1508
|
-
``ToolContext`` parameter is filled from the request headers and
|
|
1509
|
-
from the params."""
|
|
1510
|
-
slot = _tool_context_slot(fn)
|
|
1522
|
+
``ToolContext`` parameter is filled from the request headers, and a ``Request`` one with the request itself;
|
|
1523
|
+
neither is bound from the params."""
|
|
1524
|
+
slot, request_slot = _tool_context_slot(fn), _request_slot(fn)
|
|
1511
1525
|
signature = inspect.signature(fn)
|
|
1512
|
-
|
|
1513
|
-
|
|
1526
|
+
signature = signature.replace(parameters=[p for p in signature.parameters.values()
|
|
1527
|
+
if p.name not in (slot, request_slot)])
|
|
1514
1528
|
op = fn.__name__ or "invoke"
|
|
1515
1529
|
|
|
1516
1530
|
async def handler(request: Request) -> Response:
|
|
@@ -1530,7 +1544,8 @@ def _extension_handler(fn: Callable, http_method: str) -> Callable:
|
|
|
1530
1544
|
except TypeError as e:
|
|
1531
1545
|
return _error_response(AgentEnvError("invalid_params", str(e)))
|
|
1532
1546
|
with bound(context_from_http(request, op, params)) as tool_context:
|
|
1533
|
-
call_kwargs = {**params, slot: tool_context} if slot else
|
|
1547
|
+
call_kwargs = {**params, **({slot: tool_context} if slot else {}),
|
|
1548
|
+
**({request_slot: request} if request_slot else {})}
|
|
1534
1549
|
try:
|
|
1535
1550
|
result = await _maybe_await(fn(**call_kwargs))
|
|
1536
1551
|
except AgentEnvError as e:
|
|
@@ -1574,6 +1589,16 @@ def control_guard() -> Callable[[Callable], Callable]:
|
|
|
1574
1589
|
return guard
|
|
1575
1590
|
|
|
1576
1591
|
|
|
1592
|
+
def add_websocket_route(app: Any, path: str, endpoint: Callable) -> None:
|
|
1593
|
+
"""``endpoint`` served at ``path`` as a WebSocket route of ``app``, an mcp or a standalone fastmcp FastMCP, which
|
|
1594
|
+
have no API for one."""
|
|
1595
|
+
for routes in ("_custom_starlette_routes", "_additional_http_routes"):
|
|
1596
|
+
if isinstance(getattr(app, routes, None), list):
|
|
1597
|
+
getattr(app, routes).append(WebSocketRoute(path, endpoint))
|
|
1598
|
+
return
|
|
1599
|
+
raise TypeError(f"WebSocket routes go on an mcp or a fastmcp FastMCP, not a {type(app).__name__}")
|
|
1600
|
+
|
|
1601
|
+
|
|
1577
1602
|
def _tool_access_handler(access: ToolAccess, action: str) -> Callable:
|
|
1578
1603
|
async def handler(request: Request) -> Response:
|
|
1579
1604
|
raw = await request.body()
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""The CDP addresses a website browser hands agents: a token agentenv mints for each user, of which only a hash is kept
|
|
2
|
+
where the computer's session can read it, discovery answered from the browser, and a relay that gives each WebSocket
|
|
3
|
+
connection its own browser-level session over the browser's one debugging pipe, with a guard on every command."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import contextlib
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Literal, Optional
|
|
17
|
+
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .website_browser import DevToolsPipe
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
#: How many random bytes a token holds.
|
|
27
|
+
TOKEN_BYTES = 32
|
|
28
|
+
#: The route agents reach the relay under, through the driver's /svc/<app>/.
|
|
29
|
+
ROUTE = "/cdp/"
|
|
30
|
+
#: The close code and reason a connection gets when its browser goes away, which a client may connect again after.
|
|
31
|
+
RELAUNCHED = (1012, "the browser relaunched: connect again")
|
|
32
|
+
#: The close code and reason a connection gets when its client falls behind by more than ``OUTBOX_LIMIT_BYTES``.
|
|
33
|
+
TOO_SLOW = (1013, "the client fell too far behind: connect again")
|
|
34
|
+
#: How much a connection's client may have waiting to be sent to it before it is closed; a single answer bigger than
|
|
35
|
+
#: this still goes when nothing else waits.
|
|
36
|
+
OUTBOX_LIMIT_BYTES = 64 << 20
|
|
37
|
+
#: The close code and reason a connection gets after it closes or crashes the browser, which ends only it.
|
|
38
|
+
CLOSED = (1000, "the browser stays up for the env: only this connection ended")
|
|
39
|
+
#: CDP's own answer to a command for a session the connection doesn't have.
|
|
40
|
+
NO_SESSION = {"code": -32001, "message": "Session with given id not found."}
|
|
41
|
+
#: The code of a CDP error for a command not carried out, as Chromium answers its own refusals.
|
|
42
|
+
SERVER_ERROR = -32000
|
|
43
|
+
#: The answer to a message that isn't a command the relay can read, with JSON-RPC's code for an invalid request.
|
|
44
|
+
MALFORMED = {"code": -32600, "message": "a command is an object with a string method, and object params and a "
|
|
45
|
+
"string sessionId if any"}
|
|
46
|
+
#: The commands that would end the browser every connection shares: each ends only the connection that sends it.
|
|
47
|
+
ENDS_BROWSER = frozenset({"Browser.close", "Browser.crash", "Browser.crashGpuProcess"})
|
|
48
|
+
#: The commands that set how and where downloads are saved, which agentenv keeps as its own.
|
|
49
|
+
SETS_DOWNLOADS = frozenset({"Browser.setDownloadBehavior", "Page.setDownloadBehavior"})
|
|
50
|
+
#: The schemes of the browser's own files, settings and tools, which an agent's navigation or new tab may not open.
|
|
51
|
+
OWN_SCHEMES = frozenset({"file", "chrome", "chrome-extension", "chrome-untrusted", "view-source", "devtools"})
|
|
52
|
+
#: The ``about:`` pages that aren't the browser's own under another name.
|
|
53
|
+
ABOUT_PAGES = frozenset({"about:blank", "about:srcdoc"})
|
|
54
|
+
#: How many of a connection's guarded commands its record keeps.
|
|
55
|
+
GUARDED_KEPT = 100
|
|
56
|
+
#: What the record says the guard did, by its action.
|
|
57
|
+
GUARDED = {"refuse": "refused", "send": "changed", "answer": "answered", "close": "closed"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Address:
|
|
62
|
+
"""A user's CDP address: ``http_url``, the base clients discover from, and ``ws_url``, the browser's WebSocket."""
|
|
63
|
+
|
|
64
|
+
user: str
|
|
65
|
+
http_url: str
|
|
66
|
+
ws_url: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reached(request: Request) -> str:
|
|
70
|
+
"""The address a request reached the browser at, scheme, host and path prefix: what the driver forwarded, else the
|
|
71
|
+
request's own."""
|
|
72
|
+
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
|
73
|
+
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
|
|
74
|
+
return f"{proto}://{host}{request.headers.get('x-forwarded-prefix', '')}".rstrip("/")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def websocket_form(url: str) -> str:
|
|
78
|
+
return "wss://" + url.removeprefix("https://") if url.startswith("https://") else \
|
|
79
|
+
"ws://" + url.removeprefix("http://")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _hash(token: str) -> str:
|
|
83
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _address(user: str, base: str, token: str) -> Address:
|
|
87
|
+
root = f"{base}{ROUTE}{token}"
|
|
88
|
+
return Address(user, f"{root}/", f"{websocket_form(root)}/devtools/browser")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Tokens:
|
|
92
|
+
"""The tokens minted, one per user: in memory, so asking twice gives the same address, and in ``path`` only by
|
|
93
|
+
hash, with the user and the base address, so an address still works after the server restarts while the
|
|
94
|
+
computer's session, which can read the file, can't use one."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, path: Path) -> None:
|
|
97
|
+
self.path = path
|
|
98
|
+
self._minted: dict[str, Address] = {}
|
|
99
|
+
try:
|
|
100
|
+
stored = json.loads(path.read_text())
|
|
101
|
+
self._known = {digest: (entry["user"], entry["base"]) for digest, entry in stored.items()}
|
|
102
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
103
|
+
self._known = {}
|
|
104
|
+
|
|
105
|
+
def mint(self, user: str, base: str) -> Address:
|
|
106
|
+
"""``user``'s address under ``base``: the one minted before, else a new one."""
|
|
107
|
+
if user not in self._minted:
|
|
108
|
+
token = secrets.token_urlsafe(TOKEN_BYTES)
|
|
109
|
+
self._minted[user] = _address(user, base, token)
|
|
110
|
+
self._known[_hash(token)] = (user, base)
|
|
111
|
+
self._save()
|
|
112
|
+
return self._minted[user]
|
|
113
|
+
|
|
114
|
+
def address(self, token: str) -> Optional[Address]:
|
|
115
|
+
"""The address ``token`` was minted for, or None for one that wasn't."""
|
|
116
|
+
known = self._known.get(_hash(token))
|
|
117
|
+
return _address(*known, token) if known is not None else None
|
|
118
|
+
|
|
119
|
+
def _save(self) -> None:
|
|
120
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
part = self.path.with_name(f".{self.path.name}.part")
|
|
122
|
+
part.write_text(json.dumps({digest: {"user": user, "base": base}
|
|
123
|
+
for digest, (user, base) in self._known.items()}))
|
|
124
|
+
os.replace(part, self.path)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Verdict:
|
|
129
|
+
"""What the guard does with an agent's command: ``send`` it on with ``params``, ``changed`` or not; ``answer`` it
|
|
130
|
+
with ``{}`` itself; answer it and ``close`` the connection; or ``refuse`` it for ``reason``."""
|
|
131
|
+
|
|
132
|
+
action: Literal["send", "answer", "close", "refuse"]
|
|
133
|
+
params: dict
|
|
134
|
+
reason: str = ""
|
|
135
|
+
changed: bool = False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def guard(method: str, params: dict, *, dialogs_answered: bool) -> Verdict:
|
|
139
|
+
"""What the relay does with an agent's ``method``, so it can't end the browser others share, route around the site
|
|
140
|
+
proxy, save downloads elsewhere, open the browser's own pages or race the dialogs agentenv answers when
|
|
141
|
+
``dialogs_answered``: a guardrail, since the sandbox's network is the boundary."""
|
|
142
|
+
if method in ENDS_BROWSER:
|
|
143
|
+
return Verdict("close", params)
|
|
144
|
+
if method == "Target.exposeDevToolsProtocol":
|
|
145
|
+
return Verdict("refuse", params, "it would give a page's script the browser's own CDP channel")
|
|
146
|
+
if method == "Target.sendMessageToTarget":
|
|
147
|
+
return Verdict("refuse", params, "only flattened sessions are served, whose commands the guard sees")
|
|
148
|
+
if method == "Target.createBrowserContext" and {"proxyServer", "proxyBypassList"} & params.keys():
|
|
149
|
+
return Verdict("refuse", params, "a context's traffic goes through the env's site proxy")
|
|
150
|
+
if method in ("Page.navigate", "Target.createTarget") and (scheme := _own_scheme(params.get("url"))):
|
|
151
|
+
return Verdict("refuse", params, f"{scheme}: URLs open the browser's own files, settings and tools")
|
|
152
|
+
if method in SETS_DOWNLOADS:
|
|
153
|
+
pinned = {"behavior": "default",
|
|
154
|
+
**{key: params[key] for key in ("browserContextId", "eventsEnabled") if key in params}}
|
|
155
|
+
return Verdict("send", pinned, changed=pinned != params)
|
|
156
|
+
if method == "Page.handleJavaScriptDialog" and dialogs_answered:
|
|
157
|
+
return Verdict("answer", params)
|
|
158
|
+
return Verdict("send", params)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _own_scheme(url: Any) -> Optional[str]:
|
|
162
|
+
"""``url``'s scheme when it's one of the browser's own, read as the browser reads it, without tabs and newlines and
|
|
163
|
+
with spaces and controls trimmed from its ends; an ``about:`` page other than a blank one counts."""
|
|
164
|
+
if not isinstance(url, str):
|
|
165
|
+
return None
|
|
166
|
+
url = url.translate({ord(c): None for c in "\t\n\r"}).strip("".join(map(chr, range(0x21))))
|
|
167
|
+
scheme, colon, _ = url.partition(":")
|
|
168
|
+
scheme = scheme.lower()
|
|
169
|
+
if not colon:
|
|
170
|
+
return None
|
|
171
|
+
if scheme == "about":
|
|
172
|
+
return None if url.lower().split("#")[0].split("?")[0] in ABOUT_PAGES else scheme
|
|
173
|
+
return scheme if scheme in OWN_SCHEMES else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _Connection:
|
|
177
|
+
"""One client's WebSocket, its sessions over the pipe (its browser-level one and those it attached), its unanswered
|
|
178
|
+
commands, what waits to be sent to it, and its entry in the record."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, websocket: WebSocket, user: str, session: str, record: dict) -> None:
|
|
181
|
+
self.websocket, self.user, self.session, self.record = websocket, user, session, record
|
|
182
|
+
self.sessions = {session}
|
|
183
|
+
self.commands: set[asyncio.Future] = set()
|
|
184
|
+
self.outbox: asyncio.Queue[str | tuple[int, str]] = asyncio.Queue()
|
|
185
|
+
self.waiting_bytes = 0
|
|
186
|
+
self.closing = False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Relay:
|
|
190
|
+
"""Each WebSocket connection given its own browser-level session over ``devtools``, its commands sent on it past the
|
|
191
|
+
guard and the answers and events of its sessions sent back, so several clients share one browser; and a record of
|
|
192
|
+
who connected when, and what the guard did to their commands. ``dialogs_answered`` says agentenv answers the
|
|
193
|
+
browser's dialogs itself."""
|
|
194
|
+
|
|
195
|
+
def __init__(self, devtools: DevToolsPipe, *, dialogs_answered: bool = False) -> None:
|
|
196
|
+
self.devtools, self.dialogs_answered = devtools, dialogs_answered
|
|
197
|
+
self.connections: list[dict] = []
|
|
198
|
+
self._by_session: dict[str, _Connection] = {}
|
|
199
|
+
self._open: set[_Connection] = set()
|
|
200
|
+
devtools.listen(self._event)
|
|
201
|
+
|
|
202
|
+
async def serve(self, websocket: WebSocket, user: str) -> None:
|
|
203
|
+
"""``websocket``, a client of ``user``'s, relayed over a browser-level session of its own until either ends."""
|
|
204
|
+
try:
|
|
205
|
+
session = (await self.devtools.call("Target.attachToBrowserTarget"))["sessionId"]
|
|
206
|
+
except Exception as e:
|
|
207
|
+
await websocket.close(code=1013, reason=f"the browser isn't answering: {type(e).__name__}")
|
|
208
|
+
return
|
|
209
|
+
record = {"user": user, "opened_at": _now(), "guarded": []}
|
|
210
|
+
connection = _Connection(websocket, user, session, record)
|
|
211
|
+
self._by_session[session] = connection
|
|
212
|
+
self._open.add(connection)
|
|
213
|
+
self.connections.append(record)
|
|
214
|
+
await websocket.accept()
|
|
215
|
+
sending = asyncio.ensure_future(self._send(connection))
|
|
216
|
+
try:
|
|
217
|
+
while True:
|
|
218
|
+
message = await websocket.receive()
|
|
219
|
+
if message["type"] == "websocket.disconnect":
|
|
220
|
+
break
|
|
221
|
+
text = message.get("text") if message.get("text") is not None else message["bytes"].decode()
|
|
222
|
+
command = asyncio.ensure_future(self._command(connection, json.loads(text)))
|
|
223
|
+
connection.commands.add(command)
|
|
224
|
+
command.add_done_callback(connection.commands.discard)
|
|
225
|
+
except (WebSocketDisconnect, ValueError):
|
|
226
|
+
pass
|
|
227
|
+
finally:
|
|
228
|
+
record["closed_at"] = _now()
|
|
229
|
+
connection.closing = True
|
|
230
|
+
self._end(connection)
|
|
231
|
+
for task in [*connection.commands, sending]:
|
|
232
|
+
task.cancel()
|
|
233
|
+
await asyncio.gather(*connection.commands, sending, return_exceptions=True)
|
|
234
|
+
with contextlib.suppress(Exception):
|
|
235
|
+
await self.devtools.call("Target.detachFromTarget", {"sessionId": session})
|
|
236
|
+
|
|
237
|
+
def ended(self) -> None:
|
|
238
|
+
"""The browser has gone: every connection is closed with a reason a client may connect again after."""
|
|
239
|
+
for connection in list(self._open):
|
|
240
|
+
self._close(connection, RELAUNCHED)
|
|
241
|
+
|
|
242
|
+
async def _command(self, connection: _Connection, message: Any) -> None:
|
|
243
|
+
if connection.closing:
|
|
244
|
+
return
|
|
245
|
+
if not _well_formed(message):
|
|
246
|
+
self._put(connection, {"id": message.get("id") if isinstance(message, dict) else None, "error": MALFORMED})
|
|
247
|
+
return
|
|
248
|
+
method, params = message["method"], message.get("params") or {}
|
|
249
|
+
session = message.get("sessionId") or connection.session
|
|
250
|
+
verdict = guard(method, params, dialogs_answered=self.dialogs_answered)
|
|
251
|
+
if session not in connection.sessions:
|
|
252
|
+
verdict, answer = None, {"error": NO_SESSION}
|
|
253
|
+
elif verdict.action == "refuse":
|
|
254
|
+
answer = {"error": {"code": SERVER_ERROR, "message": f"agentenv refuses {method}: {verdict.reason}"}}
|
|
255
|
+
elif verdict.action != "send":
|
|
256
|
+
answer = {"result": {}}
|
|
257
|
+
else:
|
|
258
|
+
try:
|
|
259
|
+
answer = await self.devtools.request(method, verdict.params, session_id=session)
|
|
260
|
+
except ConnectionError as e:
|
|
261
|
+
answer = {"error": {"code": SERVER_ERROR, "message": str(e)}}
|
|
262
|
+
if verdict is not None and (verdict.action != "send" or verdict.changed):
|
|
263
|
+
self._guarded(connection, method, verdict)
|
|
264
|
+
reply = {"id": message.get("id"), **{key: answer[key] for key in ("result", "error") if key in answer}}
|
|
265
|
+
if message.get("sessionId"):
|
|
266
|
+
reply["sessionId"] = message["sessionId"]
|
|
267
|
+
self._put(connection, reply)
|
|
268
|
+
if verdict is not None and verdict.action == "close":
|
|
269
|
+
self._close(connection, CLOSED, dropping=False)
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _guarded(connection: _Connection, method: str, verdict: Verdict) -> None:
|
|
273
|
+
"""What the guard did to ``connection``'s ``method``, in its record, up to ``GUARDED_KEPT`` of them."""
|
|
274
|
+
if len(guarded := connection.record["guarded"]) < GUARDED_KEPT:
|
|
275
|
+
guarded.append({"method": method, "did": GUARDED[verdict.action], "at": _now()})
|
|
276
|
+
|
|
277
|
+
def _event(self, message: dict) -> None:
|
|
278
|
+
connection = self._by_session.get(message.get("sessionId"))
|
|
279
|
+
if connection is None:
|
|
280
|
+
return
|
|
281
|
+
params = message.get("params") or {}
|
|
282
|
+
if message.get("method") == "Target.attachedToTarget" and params.get("sessionId"):
|
|
283
|
+
connection.sessions.add(params["sessionId"])
|
|
284
|
+
self._by_session[params["sessionId"]] = connection
|
|
285
|
+
elif message.get("method") == "Target.detachedFromTarget" and params.get("sessionId"):
|
|
286
|
+
connection.sessions.discard(params["sessionId"])
|
|
287
|
+
self._by_session.pop(params["sessionId"], None)
|
|
288
|
+
if message["sessionId"] == connection.session:
|
|
289
|
+
message = {key: value for key, value in message.items() if key != "sessionId"}
|
|
290
|
+
self._put(connection, message)
|
|
291
|
+
|
|
292
|
+
def _put(self, connection: _Connection, message: dict) -> None:
|
|
293
|
+
"""``message`` waiting to be sent to ``connection``'s client, which is closed instead, with what waited dropped,
|
|
294
|
+
once it has fallen too far behind."""
|
|
295
|
+
if connection.closing:
|
|
296
|
+
return
|
|
297
|
+
text = json.dumps(message)
|
|
298
|
+
if connection.waiting_bytes and connection.waiting_bytes + len(text) > OUTBOX_LIMIT_BYTES:
|
|
299
|
+
self._close(connection, TOO_SLOW)
|
|
300
|
+
return
|
|
301
|
+
connection.waiting_bytes += len(text)
|
|
302
|
+
connection.outbox.put_nowait(text)
|
|
303
|
+
|
|
304
|
+
def _close(self, connection: _Connection, close: tuple[int, str], *, dropping: bool = True) -> None:
|
|
305
|
+
"""``connection`` closed with ``close`` after what waits to be sent to it, or with that dropped when
|
|
306
|
+
``dropping``; nothing is sent after it."""
|
|
307
|
+
connection.closing = True
|
|
308
|
+
self._end(connection)
|
|
309
|
+
if dropping:
|
|
310
|
+
while not connection.outbox.empty():
|
|
311
|
+
connection.outbox.get_nowait()
|
|
312
|
+
connection.waiting_bytes = 0
|
|
313
|
+
connection.outbox.put_nowait(close)
|
|
314
|
+
|
|
315
|
+
def _end(self, connection: _Connection) -> None:
|
|
316
|
+
self._open.discard(connection)
|
|
317
|
+
for session in connection.sessions:
|
|
318
|
+
self._by_session.pop(session, None)
|
|
319
|
+
|
|
320
|
+
async def _send(self, connection: _Connection) -> None:
|
|
321
|
+
with contextlib.suppress(WebSocketDisconnect, RuntimeError):
|
|
322
|
+
while True:
|
|
323
|
+
message = await connection.outbox.get()
|
|
324
|
+
if isinstance(message, tuple):
|
|
325
|
+
await connection.websocket.close(code=message[0], reason=message[1])
|
|
326
|
+
return
|
|
327
|
+
connection.waiting_bytes -= len(message)
|
|
328
|
+
await connection.websocket.send_text(message)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _well_formed(message: Any) -> bool:
|
|
332
|
+
"""Whether ``message`` is a command the relay can read: an object with a string ``method``, and object ``params``
|
|
333
|
+
and a string ``sessionId`` if any."""
|
|
334
|
+
return isinstance(message, dict) and isinstance(message.get("method"), str) and \
|
|
335
|
+
isinstance(message.get("params") or {}, dict) and isinstance(message.get("sessionId") or "", str)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _now() -> str:
|
|
339
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
@@ -374,6 +374,9 @@ class ComputerAppEnvironment(AgentEnvEnvironment):
|
|
|
374
374
|
skill: ClassVar[Optional[str]] = None
|
|
375
375
|
#: How long one of its data calls may take, a hook or its paths' default, before it fails.
|
|
376
376
|
data_timeout_s: ClassVar[float] = 300.0
|
|
377
|
+
#: Path prefixes of its own server's routes, each ending in ``/``, that agents reach through the driver without the
|
|
378
|
+
#: computer's control token; the app checks a call to one itself.
|
|
379
|
+
agent_routes: ClassVar[tuple[str, ...]] = ()
|
|
377
380
|
|
|
378
381
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
379
382
|
"""Take the class's data hooks over, for the base class's handlers to serve around its contract; and serve its
|
|
@@ -473,7 +476,7 @@ class ComputerAppEnvironment(AgentEnvEnvironment):
|
|
|
473
476
|
def extension_params(self, uri: str) -> dict:
|
|
474
477
|
if uri == COMPUTER_APP:
|
|
475
478
|
return {"launch": self.launch.model_dump(mode="json", exclude_none=True) if self.launch else None,
|
|
476
|
-
"paths": self.paths}
|
|
479
|
+
"paths": self.paths, **({"agent_routes": list(self.agent_routes)} if self.agent_routes else {})}
|
|
477
480
|
return super().extension_params(uri)
|
|
478
481
|
|
|
479
482
|
def served_operations(self, operations: list[str]) -> list[str]:
|
|
@@ -84,6 +84,12 @@ def child_card(name: str, card: dict) -> dict:
|
|
|
84
84
|
return card
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def agent_routes(card: dict) -> list[str]:
|
|
88
|
+
"""The path prefixes under its own route that an app's ``card`` opens to agents, which reach them through the
|
|
89
|
+
driver without the computer's control token and the app checks itself."""
|
|
90
|
+
return list(client.extension_params(card, COMPUTER_APP).get("agent_routes") or [])
|
|
91
|
+
|
|
92
|
+
|
|
87
93
|
async def owned_paths(folders: list[Path]) -> list[str]:
|
|
88
94
|
"""The paths under the session user's home that the apps in ``folders`` keep their data in, as their cards say."""
|
|
89
95
|
cards = await asyncio.gather(*(served_card(folder) for folder in folders))
|