agentenv-framework-protocol 0.1.449__tar.gz → 0.1.453__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.
Files changed (126) hide show
  1. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/PKG-INFO +41 -2
  2. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/README.md +39 -1
  3. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/pyproject.toml +3 -2
  4. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/agent_env_environment.py +33 -8
  5. agentenv_framework_protocol-0.1.453/src/agentenv_protocol/browser_cdp.py +339 -0
  6. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/app.py +4 -1
  7. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/apps.py +6 -0
  8. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/conformance/fixture.py +57 -12
  9. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/driver.py +27 -5
  10. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/svc.py +40 -14
  11. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/website_browser.py +91 -9
  12. agentenv_framework_protocol-0.1.453/tests/test_browser_cdp.py +352 -0
  13. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_conformance.py +26 -1
  14. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_svc_forward.py +40 -6
  15. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/.gitignore +0 -0
  16. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/LICENSE +0 -0
  17. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/NOTICE +0 -0
  18. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/THIRD_PARTY_NOTICES.md +0 -0
  19. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/examples/__init__.py +0 -0
  20. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/examples/advanced_agent.py +0 -0
  21. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/examples/basic_agent.py +0 -0
  22. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/examples/custom_extensions_agent.py +0 -0
  23. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/examples/multimodal_agent.py +0 -0
  24. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/examples/reference_model.py +0 -0
  25. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/examples/streaming_agent.py +0 -0
  26. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/__init__.py +0 -0
  27. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  28. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  29. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  30. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  31. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  32. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  33. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
  34. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  35. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  36. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  37. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  38. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/client.py +0 -0
  39. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/__init__.py +0 -0
  40. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/_declare.py +0 -0
  41. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/_linux.py +0 -0
  42. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/_load.py +0 -0
  43. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/_with_fds.py +0 -0
  44. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/app_data.py +0 -0
  45. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/app_runtime.py +0 -0
  46. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/card.py +0 -0
  47. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/checks.py +0 -0
  48. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
  49. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
  50. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
  51. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/conformance/client.py +0 -0
  52. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
  53. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
  54. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/conformance/report.py +0 -0
  55. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/dataplane.py +0 -0
  56. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/errors.py +0 -0
  57. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/extensions.py +0 -0
  58. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/frames.py +0 -0
  59. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/keys.py +0 -0
  60. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/launch.py +0 -0
  61. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/manifest.py +0 -0
  62. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/operations.py +0 -0
  63. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/platform.py +0 -0
  64. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/queue.py +0 -0
  65. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/recording.py +0 -0
  66. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/results.py +0 -0
  67. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/run_install_step.py +0 -0
  68. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/runtime.py +0 -0
  69. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/seed.py +0 -0
  70. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/serve.py +0 -0
  71. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/serve_app.py +0 -0
  72. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/session.py +0 -0
  73. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/settings.py +0 -0
  74. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/skill.py +0 -0
  75. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/spaces.py +0 -0
  76. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/computer/tools.py +0 -0
  77. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/errors.py +0 -0
  78. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/manifest.py +0 -0
  79. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/preflight.py +0 -0
  80. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/tool_access.py +0 -0
  81. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/tool_client.py +0 -0
  82. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/tool_context.py +0 -0
  83. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/transfers.py +0 -0
  84. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/src/agentenv_protocol/types.py +0 -0
  85. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/computer_fake.py +0 -0
  86. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/probe_fake.py +0 -0
  87. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_a2a_agent.py +0 -0
  88. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_a2a_agent_examples.py +0 -0
  89. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_a2a_cdp_config.py +0 -0
  90. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_a2a_object_transfers.py +0 -0
  91. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_a2a_peer_agents.py +0 -0
  92. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_a2a_proxy.py +0 -0
  93. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_a2a_staging.py +0 -0
  94. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_a2a_trajectory_log.py +0 -0
  95. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_agent_env_environment.py +0 -0
  96. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_client.py +0 -0
  97. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_app_data.py +0 -0
  98. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_app_server.py +0 -0
  99. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_conformance_checks.py +0 -0
  100. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_conformance_page.py +0 -0
  101. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_conformance_recording.py +0 -0
  102. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_dataplane.py +0 -0
  103. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_declare.py +0 -0
  104. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_driver.py +0 -0
  105. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_extensions.py +0 -0
  106. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_frames.py +0 -0
  107. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_page.py +0 -0
  108. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_queue.py +0 -0
  109. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_recording.py +0 -0
  110. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_seed.py +0 -0
  111. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_serve.py +0 -0
  112. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_computer_spaces.py +0 -0
  113. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_control_token.py +0 -0
  114. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_data_objects.py +0 -0
  115. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_errors.py +0 -0
  116. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_extension_methods.py +0 -0
  117. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_intake.py +0 -0
  118. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_manifest.py +0 -0
  119. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_preflight.py +0 -0
  120. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_tool_access.py +0 -0
  121. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_tool_annotations.py +0 -0
  122. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_tool_client.py +0 -0
  123. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_tool_context.py +0 -0
  124. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_tool_context_fastmcp.py +0 -0
  125. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/tests/test_tool_context_injection.py +0 -0
  126. {agentenv_framework_protocol-0.1.449 → agentenv_framework_protocol-0.1.453}/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.449
3
+ Version: 0.1.453
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. Code that acts as a caller outside any dispatch, such as a test calling a
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. Code that acts as a caller outside any dispatch, such as a test calling a
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.449"
3
+ version = "0.1.453"
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 never bound
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
- if slot:
1513
- signature = signature.replace(parameters=[p for p in signature.parameters.values() if p.name != slot])
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 params
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))