agentenv-framework-protocol 0.1.448__tar.gz → 0.1.449__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 (124) hide show
  1. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/PKG-INFO +13 -2
  2. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/README.md +10 -1
  3. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/pyproject.toml +5 -3
  4. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/agent_env_environment.py +13 -4
  5. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/apps.py +1 -10
  6. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/driver.py +21 -13
  7. agentenv_framework_protocol-0.1.449/src/agentenv_protocol/computer/svc.py +182 -0
  8. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/website_browser.py +3 -0
  9. agentenv_framework_protocol-0.1.449/tests/test_computer_svc_forward.py +227 -0
  10. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_website_browser.py +2 -1
  11. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/.gitignore +0 -0
  12. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/LICENSE +0 -0
  13. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/NOTICE +0 -0
  14. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/THIRD_PARTY_NOTICES.md +0 -0
  15. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/__init__.py +0 -0
  16. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/advanced_agent.py +0 -0
  17. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/basic_agent.py +0 -0
  18. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/custom_extensions_agent.py +0 -0
  19. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/multimodal_agent.py +0 -0
  20. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/reference_model.py +0 -0
  21. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/streaming_agent.py +0 -0
  22. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/__init__.py +0 -0
  23. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  24. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  25. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  26. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  27. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  28. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  29. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
  30. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  31. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  32. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  33. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  34. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/client.py +0 -0
  35. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/__init__.py +0 -0
  36. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/_declare.py +0 -0
  37. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/_linux.py +0 -0
  38. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/_load.py +0 -0
  39. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/_with_fds.py +0 -0
  40. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/app.py +0 -0
  41. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/app_data.py +0 -0
  42. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/app_runtime.py +0 -0
  43. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/card.py +0 -0
  44. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/checks.py +0 -0
  45. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
  46. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
  47. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
  48. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/client.py +0 -0
  49. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
  50. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
  51. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
  52. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/report.py +0 -0
  53. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/dataplane.py +0 -0
  54. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/errors.py +0 -0
  55. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/extensions.py +0 -0
  56. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/frames.py +0 -0
  57. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/keys.py +0 -0
  58. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/launch.py +0 -0
  59. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/manifest.py +0 -0
  60. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/operations.py +0 -0
  61. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/platform.py +0 -0
  62. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/queue.py +0 -0
  63. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/recording.py +0 -0
  64. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/results.py +0 -0
  65. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/run_install_step.py +0 -0
  66. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/runtime.py +0 -0
  67. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/seed.py +0 -0
  68. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/serve.py +0 -0
  69. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/serve_app.py +0 -0
  70. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/session.py +0 -0
  71. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/settings.py +0 -0
  72. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/skill.py +0 -0
  73. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/spaces.py +0 -0
  74. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/tools.py +0 -0
  75. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/errors.py +0 -0
  76. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/manifest.py +0 -0
  77. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/preflight.py +0 -0
  78. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/tool_access.py +0 -0
  79. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/tool_client.py +0 -0
  80. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/tool_context.py +0 -0
  81. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/transfers.py +0 -0
  82. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/types.py +0 -0
  83. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/computer_fake.py +0 -0
  84. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/probe_fake.py +0 -0
  85. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_agent.py +0 -0
  86. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_agent_examples.py +0 -0
  87. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_cdp_config.py +0 -0
  88. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_object_transfers.py +0 -0
  89. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_peer_agents.py +0 -0
  90. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_proxy.py +0 -0
  91. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_staging.py +0 -0
  92. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_trajectory_log.py +0 -0
  93. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_agent_env_environment.py +0 -0
  94. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_client.py +0 -0
  95. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_app_data.py +0 -0
  96. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_app_server.py +0 -0
  97. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_conformance.py +0 -0
  98. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_conformance_checks.py +0 -0
  99. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_conformance_page.py +0 -0
  100. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_conformance_recording.py +0 -0
  101. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_dataplane.py +0 -0
  102. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_declare.py +0 -0
  103. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_driver.py +0 -0
  104. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_extensions.py +0 -0
  105. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_frames.py +0 -0
  106. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_page.py +0 -0
  107. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_queue.py +0 -0
  108. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_recording.py +0 -0
  109. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_seed.py +0 -0
  110. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_serve.py +0 -0
  111. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_spaces.py +0 -0
  112. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_control_token.py +0 -0
  113. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_data_objects.py +0 -0
  114. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_errors.py +0 -0
  115. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_extension_methods.py +0 -0
  116. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_intake.py +0 -0
  117. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_manifest.py +0 -0
  118. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_preflight.py +0 -0
  119. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_access.py +0 -0
  120. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_annotations.py +0 -0
  121. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_client.py +0 -0
  122. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_context.py +0 -0
  123. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_context_fastmcp.py +0 -0
  124. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_context_injection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentenv-framework-protocol
3
- Version: 0.1.448
3
+ Version: 0.1.449
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
@@ -42,6 +42,7 @@ Requires-Dist: av>=17; extra == 'computer'
42
42
  Requires-Dist: mcp<2,>=1.25; extra == 'computer'
43
43
  Requires-Dist: pillow>=10.4; extra == 'computer'
44
44
  Requires-Dist: uvicorn>=0.30; extra == 'computer'
45
+ Requires-Dist: websockets>=13; extra == 'computer'
45
46
  Provides-Extra: dev
46
47
  Requires-Dist: a2a-sdk[http-server]<0.4,>=0.3.26; extra == 'dev'
47
48
  Requires-Dist: av>=17; extra == 'dev'
@@ -52,6 +53,7 @@ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
52
53
  Requires-Dist: pytest>=9.0; extra == 'dev'
53
54
  Requires-Dist: python-multipart>=0.0.13; extra == 'dev'
54
55
  Requires-Dist: regex>=2023.12.25; extra == 'dev'
56
+ Requires-Dist: websockets>=13; extra == 'dev'
55
57
  Description-Content-Type: text/markdown
56
58
 
57
59
  # agentenv-framework-protocol
@@ -826,7 +828,16 @@ launches one at a time, runs a load and a reset with the app stopped and relaunc
826
828
  follows a failed load with a reset, and fails a call that runs past `data_timeout_s` (300 s); a sync
827
829
  hook runs in a thread, which the reset and relaunch after it wait for. The driver fronts its apps:
828
830
  its card lists each one's card as a child environment, rooted at `/svc/<app>/`, where it forwards
829
- requests to the app's server.
831
+ requests to the app's server, taking the control token:
832
+
833
+ - every method, its body and the answer's streamed both ways, so a large answer isn't held whole;
834
+ - headers passed on but the hop-by-hop ones, those a `Connection` header names and the control token's
835
+ `Authorization`; `Host` as the caller sent it, and in place of any address the caller forwarded,
836
+ the one it reached the driver at: `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-Prefix`
837
+ (`/svc/<app>`), as the gateway sets them;
838
+ - WebSockets, relayed to the app's server both ways with the subprotocol it picks until either side
839
+ closes, the other then closed with its code; refused before they open without the token, or with no
840
+ such app or one not answering. The `computer` extra brings `websockets` for this.
830
841
 
831
842
  `MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
832
843
  env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
@@ -770,7 +770,16 @@ launches one at a time, runs a load and a reset with the app stopped and relaunc
770
770
  follows a failed load with a reset, and fails a call that runs past `data_timeout_s` (300 s); a sync
771
771
  hook runs in a thread, which the reset and relaunch after it wait for. The driver fronts its apps:
772
772
  its card lists each one's card as a child environment, rooted at `/svc/<app>/`, where it forwards
773
- requests to the app's server.
773
+ requests to the app's server, taking the control token:
774
+
775
+ - every method, its body and the answer's streamed both ways, so a large answer isn't held whole;
776
+ - headers passed on but the hop-by-hop ones, those a `Connection` header names and the control token's
777
+ `Authorization`; `Host` as the caller sent it, and in place of any address the caller forwarded,
778
+ the one it reached the driver at: `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-Prefix`
779
+ (`/svc/<app>`), as the gateway sets them;
780
+ - WebSockets, relayed to the app's server both ways with the subprotocol it picks until either side
781
+ closes, the other then closed with its code; refused before they open without the token, or with no
782
+ such app or one not answering. The `computer` extra brings `websockets` for this.
774
783
 
775
784
  `MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
776
785
  env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.448"
3
+ version = "0.1.449"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -41,13 +41,14 @@ app = [
41
41
  "mcp>=1.25,<2",
42
42
  "uvicorn>=0.30",
43
43
  ]
44
- # A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP, crops and encodes frames, and
45
- # records the display as H.264 with PyAV (computer-recording/v1).
44
+ # A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP, crops and encodes frames, records
45
+ # the display as H.264 with PyAV (computer-recording/v1), and relays WebSockets to its apps on /svc/<app>/.
46
46
  computer = [
47
47
  "av>=17",
48
48
  "mcp>=1.25,<2",
49
49
  "Pillow>=10.4",
50
50
  "uvicorn>=0.30",
51
+ "websockets>=13",
51
52
  ]
52
53
  dev = [
53
54
  "a2a-sdk[http-server]>=0.3.26,<0.4",
@@ -59,6 +60,7 @@ dev = [
59
60
  "fastmcp>=3.4,<4", # the standalone-fastmcp mount path
60
61
  "Pillow>=10.4", # the computer extra's frame crops and encodings
61
62
  "av>=17", # the computer extra's encoder, and the tests' decoder
63
+ "websockets>=13", # the computer extra's /svc/<app>/ relay
62
64
  ]
63
65
  [project.urls]
64
66
  Homepage = "https://www.agentenvframework.com"
@@ -1546,18 +1546,27 @@ def _error_response(error: AgentEnvError) -> Response:
1546
1546
  return _json_response(error_body(**error.fields()), error.status)
1547
1547
 
1548
1548
 
1549
+ def control_check() -> Callable[[Any], bool]:
1550
+ """Whether a call's headers carry the env's control token as their bearer, with ``CONTROL_TOKEN_ENV`` set; without
1551
+ it, any do."""
1552
+ token = os.environ.get(CONTROL_TOKEN_ENV)
1553
+ if not token:
1554
+ return lambda headers: True
1555
+ expected = f"Bearer {token}".encode()
1556
+ return lambda headers: hmac.compare_digest(headers.get("authorization", "").encode(), expected)
1557
+
1558
+
1549
1559
  def control_guard() -> Callable[[Callable], Callable]:
1550
1560
  """What wraps a framework-only route, everything an env serves but its MCP endpoint and its card: with
1551
1561
  ``CONTROL_TOKEN_ENV`` set, a call that doesn't carry that token as its bearer is refused before the route runs;
1552
1562
  without it, nothing."""
1553
- token = os.environ.get(CONTROL_TOKEN_ENV)
1554
- if not token:
1563
+ if not os.environ.get(CONTROL_TOKEN_ENV):
1555
1564
  return lambda handler: handler
1556
- expected = f"Bearer {token}".encode()
1565
+ allowed = control_check()
1557
1566
 
1558
1567
  def guard(handler: Callable) -> Callable:
1559
1568
  async def guarded(request: Request) -> Response:
1560
- if not hmac.compare_digest(request.headers.get("authorization", "").encode(), expected):
1569
+ if not allowed(request.headers):
1561
1570
  return _error_response(AgentEnvError(
1562
1571
  "unauthorized", "this route is agent-env's own: it takes the env's control token", status=401))
1563
1572
  return await handler(request)
@@ -3,7 +3,7 @@ Each app's own server answers on its folder's socket (``serve_app``): its card s
3
3
  paths it owns, and its ``computer-app/v1`` ``ready`` whether it's ready. Every app's server must answer before the
4
4
  computer is ready; an app that starts with the computer must also be ready, and a ``demand`` app is checked once
5
5
  something launches it. The driver lists each app as a child environment named for its folder, its card's paths under
6
- ``/svc/<name>/``, and forwards each request there to the app's server."""
6
+ ``/svc/<name>/``, where it forwards each request to the app's server (``svc``)."""
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
@@ -91,15 +91,6 @@ async def owned_paths(folders: list[Path]) -> list[str]:
91
91
  for path in client.extension_params(card, COMPUTER_APP).get("paths") or []]
92
92
 
93
93
 
94
- async def forward(folder: Path, method: str, path: str, query: str, body: bytes,
95
- content_type: Optional[str]) -> httpx.Response:
96
- """The app's server's answer to the request, sent on as it came; it takes as long as the app does."""
97
- async with httpx.AsyncClient(transport=httpx.AsyncHTTPTransport(uds=str(folder / SOCKET)), base_url="http://app",
98
- timeout=None) as http:
99
- return await http.request(method, f"/{path}" + (f"?{query}" if query else ""), content=body,
100
- headers={"content-type": content_type} if content_type else None)
101
-
102
-
103
94
  def endpoint(card: dict, method: str) -> str:
104
95
  """The path of a ``computer-app/v1`` method, as the app's card advertises it."""
105
96
  return client.find_extension_method(card, COMPUTER_APP, method)["endpoint"]
@@ -4,21 +4,22 @@ computer's apps as child environments, each reached at ``/svc/<name>/`` through
4
4
  from __future__ import annotations
5
5
 
6
6
  import asyncio
7
- from typing import Any, Callable, ClassVar, Literal
7
+ from pathlib import Path
8
+ from typing import Any, Callable, ClassVar, Literal, Optional
8
9
 
9
- import httpx
10
10
  from pydantic import BaseModel, ConfigDict
11
11
  from starlette.requests import Request
12
12
  from starlette.responses import Response
13
+ from starlette.websockets import WebSocket
13
14
 
14
15
  from ..agent_env_environment import (_CARD_CONFIG_ATTR, AgentEnvEnvironment, AgentEnvFastMCPApplication,
15
16
  _error_response, control_guard, environment_card)
16
17
  from ..tool_context import ToolContext
17
18
  from ..types import EnvironmentCard, EnvironmentExtension, EnvironmentTool
18
- from . import _linux, apps, card
19
+ from . import _linux, apps, card, svc
19
20
  from .checks import check_features, check_names
20
21
  from .dataplane import DataPlane
21
- from .errors import DeliveryFailed, TargetUnavailable
22
+ from .errors import TargetUnavailable
22
23
  from .extensions import Extensions
23
24
  from .operations import ReadyCheck, check_class, default_for
24
25
  from .platform import Platform, detect
@@ -138,21 +139,28 @@ class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironmen
138
139
  return await apps.children(self._computer.apps) if self._computer.apps else None
139
140
 
140
141
  def _computer_front_apps(self, app: Any) -> Any:
141
- app.custom_route("/svc/{name}/{path:path}", methods=["GET", "POST"])(control_guard()(self._computer_forward))
142
+ app.custom_route("/svc/{name}/{path:path}", methods=svc.METHODS)(control_guard()(self._computer_forward))
143
+ svc.websocket_route(app, "/svc/{name}/{path:path}", self._computer_relay)
142
144
  return app
143
145
 
146
+ def _computer_app_folder(self, name: str) -> Optional[Path]:
147
+ return next((folder for folder in self._computer.apps if folder.name == name), None)
148
+
144
149
  async def _computer_forward(self, request: Request) -> Response:
145
150
  """A request under ``/svc/<name>/``, sent on to the app named so: its own server answers it."""
146
151
  name = request.path_params["name"]
147
- folder = next((folder for folder in self._computer.apps if folder.name == name), None)
148
- if folder is None:
152
+ if (folder := self._computer_app_folder(name)) is None:
149
153
  return _error_response(TargetUnavailable(f"this computer has no app named {name!r}"))
150
- try:
151
- answer = await apps.forward(folder, request.method, request.path_params["path"], request.url.query,
152
- await request.body(), request.headers.get("content-type"))
153
- except (httpx.HTTPError, OSError) as e:
154
- return _error_response(DeliveryFailed(f"app {name}'s server isn't answering: {type(e).__name__}: {e}"))
155
- return Response(answer.content, status_code=answer.status_code, media_type=answer.headers.get("content-type"))
154
+ return await svc.forward(folder, name, request)
155
+
156
+ async def _computer_relay(self, websocket: WebSocket) -> None:
157
+ """A WebSocket under ``/svc/<name>/``, relayed both ways to the app named so; refused, before it opens, with no
158
+ such app."""
159
+ name = websocket.path_params["name"]
160
+ if (folder := self._computer_app_folder(name)) is None:
161
+ await websocket.close(code=1008)
162
+ return
163
+ await svc.relay(folder, name, websocket)
156
164
 
157
165
  # The card: what the driver's operations support.
158
166
 
@@ -0,0 +1,182 @@
1
+ """The driver's ``/svc/<name>/`` route to each app on its computer: a request sent on to the app's server with its body
2
+ and its answer streamed, and a WebSocket relayed to it both ways. It needs the ``computer`` extra's ``websockets``, so
3
+ only the driver imports it; an app's venv, with the ``app`` extra, imports ``apps`` and never this."""
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import contextlib
8
+ import re
9
+ from collections.abc import AsyncIterator, Iterable
10
+ from pathlib import Path
11
+ from typing import Any, Callable
12
+ from urllib.parse import quote, unquote
13
+
14
+ import httpx
15
+ import websockets
16
+ from starlette.background import BackgroundTask
17
+ from starlette.requests import Request
18
+ from starlette.responses import Response, StreamingResponse
19
+ from starlette.routing import WebSocketRoute
20
+ from starlette.websockets import WebSocket, WebSocketDisconnect
21
+ from websockets.asyncio.client import ClientConnection, unix_connect
22
+
23
+ from ..agent_env_environment import _error_response, control_check
24
+ from .app import SOCKET
25
+ from .errors import DeliveryFailed
26
+
27
+ #: The methods the route forwards.
28
+ METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
29
+ #: The headers that belong to one connection, which a proxy doesn't pass on.
30
+ HOP_HEADERS = frozenset({"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection",
31
+ "te", "trailer", "transfer-encoding", "upgrade"})
32
+ #: The address a request reached the driver at, which the driver sets for the app in place of any the caller sent.
33
+ FORWARDED = ("x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix")
34
+ #: A WebSocket close code that means the connection ended without one, which a close can't send on.
35
+ UNSENDABLE_CLOSES = (1005, 1006, 1015)
36
+ #: A Host header that names a host, and its port or not, and nothing else.
37
+ HOST = re.compile(r"(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?")
38
+ #: What a path's segments may hold unescaped, besides letters, digits and ``_.-~``.
39
+ PATH_SAFE = "/:@!$&'()*+,;="
40
+
41
+
42
+ def passed_on(headers: Iterable[tuple[str, str]], *dropped: str) -> list[tuple[str, str]]:
43
+ """``headers`` as a proxy passes them on: without the hop-by-hop ones, those a ``Connection`` header names, and
44
+ ``dropped``."""
45
+ headers = list(headers)
46
+ named = {token.strip().lower() for name, value in headers if name.lower() == "connection"
47
+ for token in value.split(",")}
48
+ skipped = HOP_HEADERS | named | set(dropped)
49
+ return [(name, value) for name, value in headers if name.lower() not in skipped]
50
+
51
+
52
+ def to_app(headers: Iterable[tuple[str, str]], host: str, scheme: str, prefix: str) -> list[tuple[str, str]]:
53
+ """A caller's headers as the app gets them: passed on without the control token's ``Authorization`` or any address
54
+ the caller forwarded, and with the one it reached the driver at, its ``host``, ``scheme`` and the app's
55
+ ``prefix``."""
56
+ return passed_on(headers, "authorization", *FORWARDED) + [
57
+ ("x-forwarded-host", host), ("x-forwarded-proto", scheme), ("x-forwarded-prefix", prefix)]
58
+
59
+
60
+ def target(connection: Request | WebSocket) -> str:
61
+ """The path and query a request under ``/svc/<name>/`` asks its app for, as it came: still percent-encoded, so an
62
+ escaped ``?``, ``#`` or ``/`` stays one. The raw path's segments past any the driver is mounted under must be
63
+ ``svc`` and the app's name, else the routed path is escaped again."""
64
+ mounted = len([segment for segment in connection.scope.get("root_path", "").split("/") if segment])
65
+ parts = (connection.scope.get("raw_path") or b"").decode("latin-1").split("/")[1 + mounted:]
66
+ if len(parts) > 2 and [unquote(part) for part in parts[:2]] == ["svc", connection.path_params["name"]]:
67
+ tail = "/".join(parts[2:])
68
+ else:
69
+ tail = quote(connection.path_params["path"], safe=PATH_SAFE)
70
+ query = connection.scope.get("query_string", b"").decode("latin-1")
71
+ return f"/{tail}" + (f"?{query}" if query else "")
72
+
73
+
74
+ def _bytes(headers: Iterable[tuple[str, str]]) -> list[tuple[bytes, bytes]]:
75
+ """Headers decoded as Latin-1, as Starlette and the helpers here hold them, as the bytes they came as."""
76
+ return [(name.encode("latin-1"), value.encode("latin-1")) for name, value in headers]
77
+
78
+
79
+ def websocket_route(app: Any, path: str, endpoint: Callable) -> None:
80
+ """``endpoint`` served at ``path`` as a WebSocket route of ``app``, an mcp or a standalone fastmcp FastMCP, which
81
+ have no API for one."""
82
+ for routes in ("_custom_starlette_routes", "_additional_http_routes"):
83
+ if isinstance(getattr(app, routes, None), list):
84
+ getattr(app, routes).append(WebSocketRoute(path, endpoint))
85
+ return
86
+ raise TypeError(f"a driver's app takes WebSocket routes on an mcp or a fastmcp FastMCP, not a {type(app).__name__}")
87
+
88
+
89
+ async def forward(folder: Path, name: str, request: Request) -> Response:
90
+ """``request`` sent on to the app ``name``'s server in ``folder`` with the caller's headers and none of the
91
+ client's own, its body and its answer streamed; it takes as long as the app does."""
92
+ headers = to_app(request.headers.items(), request.headers.get("host", ""), request.url.scheme, f"/svc/{name}")
93
+ has_body = request.headers.get("content-length", "0") != "0" or "transfer-encoding" in request.headers
94
+ http = httpx.AsyncClient(transport=httpx.AsyncHTTPTransport(uds=str(folder / SOCKET)), base_url="http://app",
95
+ timeout=None)
96
+ http.headers.clear()
97
+ try:
98
+ answer = await http.send(http.build_request(request.method, target(request), headers=_bytes(headers),
99
+ content=request.stream() if has_body else None), stream=True)
100
+ except (httpx.HTTPError, OSError) as e:
101
+ await http.aclose()
102
+ return _error_response(DeliveryFailed(f"app {name}'s server isn't answering: {type(e).__name__}: {e}"))
103
+ except BaseException:
104
+ await http.aclose()
105
+ raise
106
+
107
+ async def close() -> None:
108
+ await answer.aclose()
109
+ await http.aclose()
110
+
111
+ async def streamed() -> AsyncIterator[bytes]:
112
+ try:
113
+ async for chunk in answer.aiter_raw():
114
+ yield chunk
115
+ finally:
116
+ await close()
117
+
118
+ response = StreamingResponse(streamed(), status_code=answer.status_code, background=BackgroundTask(close))
119
+ response.raw_headers = _bytes(passed_on((name.decode("latin-1"), value.decode("latin-1"))
120
+ for name, value in answer.headers.raw))
121
+ return response
122
+
123
+
124
+ async def relay(folder: Path, name: str, websocket: WebSocket) -> None:
125
+ """``websocket`` relayed both ways to the app ``name``'s server in ``folder`` until either side closes; refused,
126
+ before it opens, without the control token, with a Host that isn't a host and port, or with the app's server not
127
+ answering."""
128
+ host = websocket.headers.get("host", "")
129
+ if not control_check()(websocket.headers) or (host and not HOST.fullmatch(host)):
130
+ await websocket.close(code=1008)
131
+ return
132
+ headers = [(key, _utf8(value)) for key, value in to_app(
133
+ websocket.headers.items(), host, "https" if websocket.url.scheme == "wss" else "http", f"/svc/{name}")
134
+ if key.lower() != "host" and not key.lower().startswith("sec-websocket-")]
135
+ offered = [p.strip() for p in websocket.headers.get("sec-websocket-protocol", "").split(",") if p.strip()]
136
+ try:
137
+ upstream = await unix_connect(str(folder / SOCKET), f"ws://{host or 'app'}{target(websocket)}",
138
+ additional_headers=headers, subprotocols=offered or None, max_size=None,
139
+ compression=None, ping_interval=None, user_agent_header=None)
140
+ except (OSError, ValueError, asyncio.TimeoutError, websockets.WebSocketException):
141
+ await websocket.close(code=1011)
142
+ return
143
+ async with upstream:
144
+ await websocket.accept(subprotocol=upstream.subprotocol)
145
+ await _both_ways(websocket, upstream)
146
+
147
+
148
+ def _utf8(value: str) -> str:
149
+ """A header value decoded as Latin-1 as the string ``websockets`` sends as the same bytes, which it encodes as
150
+ UTF-8; bytes that aren't UTF-8 become replacement characters."""
151
+ return value.encode("latin-1").decode("utf-8", "replace")
152
+
153
+
154
+ async def _both_ways(client: WebSocket, app: ClientConnection) -> None:
155
+ """Each message from either side sent to the other until one side closes, then the other closed with its code."""
156
+ async def inbound() -> None:
157
+ while True:
158
+ message = await client.receive()
159
+ if message["type"] == "websocket.disconnect":
160
+ code = message.get("code") or 1000
161
+ await app.close(1000 if code in UNSENDABLE_CLOSES else code)
162
+ return
163
+ await app.send(message["text"] if message.get("text") is not None else message["bytes"])
164
+
165
+ async def outbound() -> None:
166
+ with contextlib.suppress(websockets.ConnectionClosed):
167
+ async for message in app:
168
+ if isinstance(message, str):
169
+ await client.send_text(message)
170
+ else:
171
+ await client.send_bytes(message)
172
+ code = app.close_code or 1000
173
+ with contextlib.suppress(RuntimeError, WebSocketDisconnect):
174
+ await client.close(1000 if code in UNSENDABLE_CLOSES else code)
175
+
176
+ tasks = [asyncio.ensure_future(inbound()), asyncio.ensure_future(outbound())]
177
+ try:
178
+ await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
179
+ finally:
180
+ for task in tasks:
181
+ task.cancel()
182
+ await asyncio.gather(*tasks, return_exceptions=True)
@@ -430,7 +430,10 @@ class BrowserControl:
430
430
  return {**({"png": png} if png is not None else {"blank": True}), "page": self._state_of(target)}
431
431
 
432
432
  async def _capture(self, session: str) -> Optional[str]:
433
+ """The tab's page as it shows now; with a dialog open on it, which keeps it from painting, the last it took."""
433
434
  target = self._sessions[session]
435
+ if session in self._open_dialogs:
436
+ return self._shots.get(target)
434
437
  navigated = self.tabs.get(target, {}).get("url")
435
438
  try:
436
439
  shot = await self.devtools.call("Page.captureScreenshot", {"format": "png", "optimizeForSpeed": True},
@@ -0,0 +1,227 @@
1
+ """The driver's /svc/<app>/ in front of an app's own server on its socket: a WebSocket is relayed both ways, with the
2
+ subprotocol the app picks, until the app closes it with its code, and only with the control token and a Host that's a
3
+ host; an answer streams through as the app sends it, 20 MB whole; every method and body goes on to the path as it came,
4
+ still escaped, and the caller's headers, and only those, pass on as their bytes but for the hop-by-hop ones, the
5
+ control token and any address the caller forwarded, which the driver sets as the one it was reached at."""
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import hashlib
11
+ import json
12
+ import tempfile
13
+ from pathlib import Path
14
+ from types import SimpleNamespace
15
+
16
+ import httpx
17
+ import pytest
18
+ import uvicorn
19
+
20
+ pytest.importorskip("mcp.server.fastmcp")
21
+ pytest.importorskip("PIL")
22
+
23
+ from computer_fake import FakeDriver # noqa: E402
24
+ from probe_fake import served # noqa: E402
25
+ from starlette.requests import Request # noqa: E402
26
+ from starlette.responses import JSONResponse, Response, StreamingResponse # noqa: E402
27
+ from starlette.routing import WebSocketRoute # noqa: E402
28
+ from starlette.websockets import WebSocket # noqa: E402
29
+ from websockets.asyncio.client import connect # noqa: E402
30
+ from websockets.exceptions import ConnectionClosedError, InvalidStatus # noqa: E402
31
+
32
+ from agentenv_protocol import CONTROL_TOKEN_ENV, client # noqa: E402
33
+ from agentenv_protocol.computer import ComputerAppEnvironment, apps, linux, svc # noqa: E402
34
+ from agentenv_protocol.computer.app import SOCKET # noqa: E402
35
+
36
+ MB = 1 << 20
37
+ TOKEN = "s3cret"
38
+
39
+
40
+ @linux()
41
+ class Echo(ComputerAppEnvironment):
42
+ """An app whose own server echoes what a WebSocket sends it until "bye", which it closes with 4001; answers with
43
+ the headers it got and the size of what it was sent; and streams 20 MB, its first MB at once and the rest once
44
+ ``released``."""
45
+
46
+ def __init__(self) -> None:
47
+ super().__init__()
48
+ self.released = asyncio.Event()
49
+
50
+ def create_app(self):
51
+ mcp = super().create_app()
52
+ mcp.custom_route("/headers", methods=["GET"])(self._headers)
53
+ mcp.custom_route("/upload", methods=["PUT", "DELETE"])(self._upload)
54
+ mcp.custom_route("/big", methods=["GET"])(self._big)
55
+ mcp.custom_route("/raw/{rest:path}", methods=["GET"])(self._raw)
56
+ mcp._custom_starlette_routes += [WebSocketRoute("/echo", self._echo), WebSocketRoute("/raw/{rest:path}",
57
+ self._raw_socket)]
58
+ return mcp
59
+
60
+ async def _raw(self, request: Request) -> Response:
61
+ scope = request.scope
62
+ return JSONResponse({"path": scope["raw_path"].decode(), "query": scope["query_string"].decode()})
63
+
64
+ async def _raw_socket(self, websocket: WebSocket) -> None:
65
+ await websocket.accept()
66
+ await websocket.send_text(websocket.scope["raw_path"].decode())
67
+ await websocket.close()
68
+
69
+ async def _headers(self, request: Request) -> Response:
70
+ answer = JSONResponse({"host": request.headers.get("host"), "headers": dict(request.headers.items())})
71
+ for header in ("set-cookie: a=1", "set-cookie: b=2", "connection: x-secret", "x-secret: 1", "x-kept: 1",
72
+ 'content-disposition: attachment; filename="报告.zip"'):
73
+ name, _, value = header.partition(": ")
74
+ answer.raw_headers.append((name.encode(), value.encode()))
75
+ return answer
76
+
77
+ async def _upload(self, request: Request) -> Response:
78
+ body = await request.body()
79
+ return JSONResponse({"method": request.method, "bytes": len(body), "sha256": hashlib.sha256(body).hexdigest()})
80
+
81
+ async def _big(self, request: Request) -> Response:
82
+ async def chunks():
83
+ yield b"a" * MB
84
+ await self.released.wait()
85
+ for _ in range(19):
86
+ yield b"b" * MB
87
+ return StreamingResponse(chunks(), media_type="application/octet-stream")
88
+
89
+ async def _echo(self, websocket: WebSocket) -> None:
90
+ await websocket.accept(subprotocol="cdp" if "cdp" in websocket.scope.get("subprotocols", []) else None)
91
+ await websocket.send_text(json.dumps({"forwarded": websocket.headers.get("x-forwarded-prefix"),
92
+ "authorization": websocket.headers.get("authorization")}))
93
+ while (message := await websocket.receive())["type"] != "websocket.disconnect":
94
+ if message.get("text") == "bye":
95
+ await websocket.close(code=4001)
96
+ return
97
+ await (websocket.send_text(message["text"]) if message.get("text") is not None
98
+ else websocket.send_bytes(message["bytes"][::-1]))
99
+
100
+
101
+ @contextlib.asynccontextmanager
102
+ async def _computer(monkeypatch):
103
+ """The Echo app served on its socket in a computer's apps folder, and a driver in front of it on loopback, with a
104
+ control token; the app, and the driver's URL."""
105
+ with tempfile.TemporaryDirectory(dir="/tmp", prefix="apps-") as root:
106
+ folder = Path(root) / "echo"
107
+ (folder / SOCKET).parent.mkdir(parents=True)
108
+ monkeypatch.setattr(apps, "APPS", Path(root))
109
+ monkeypatch.setenv("ENVIRONMENT_NAME", "echo")
110
+ app = Echo()
111
+ server = uvicorn.Server(uvicorn.Config(app.create_app().streamable_http_app(), uds=str(folder / SOCKET),
112
+ log_level="warning", lifespan="off"))
113
+ serving = asyncio.create_task(server.serve())
114
+ while not server.started:
115
+ await asyncio.sleep(0.01)
116
+ monkeypatch.delenv("ENVIRONMENT_NAME")
117
+ monkeypatch.setenv(CONTROL_TOKEN_ENV, TOKEN)
118
+ try:
119
+ with served(FakeDriver()) as url:
120
+ yield app, url
121
+ finally:
122
+ server.should_exit = True
123
+ await serving
124
+
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_a_websocket_through_svc_reaches_the_app_both_ways_until_it_closes_and_only_with_the_token(monkeypatch):
128
+ async with _computer(monkeypatch) as (_, url):
129
+ ws = url.replace("http", "ws") + "/svc/echo/echo"
130
+ async with connect(ws, additional_headers=client.control_headers(TOKEN), subprotocols=["cdp"]) as socket:
131
+ hello = json.loads(await socket.recv())
132
+ await socket.send("Page.navigate")
133
+ text = await socket.recv()
134
+ await socket.send(b"\x01\x02\x03")
135
+ data = await socket.recv()
136
+ await socket.send("bye")
137
+ with pytest.raises(ConnectionClosedError) as closed:
138
+ await socket.recv()
139
+ subprotocol = socket.subprotocol
140
+ refused = []
141
+ for headers, path in (({}, "/svc/echo/echo"), (client.control_headers("guess"), "/svc/echo/echo"),
142
+ (client.control_headers(TOKEN), "/svc/notes/echo")):
143
+ with pytest.raises(InvalidStatus) as status:
144
+ async with connect(url.replace("http", "ws") + path, additional_headers=headers):
145
+ pass
146
+ refused.append(status.value.response.status_code)
147
+ malformed = [await _handshake(url, host) for host in ("x/y", "[::1")]
148
+ async with connect(url.replace("http", "ws") + "/svc/echo/raw/a%3Fb",
149
+ additional_headers=client.control_headers(TOKEN)) as socket:
150
+ raw = await socket.recv()
151
+
152
+ assert hello == {"forwarded": "/svc/echo", "authorization": None} and subprotocol == "cdp"
153
+ assert (text, data) == ("Page.navigate", b"\x03\x02\x01") and closed.value.rcvd.code == 4001
154
+ assert refused == [403, 403, 403] and malformed == [b"HTTP/1.1 403 Forbidden", b"HTTP/1.1 403 Forbidden"]
155
+ assert raw == "/raw/a%3Fb"
156
+
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_an_answer_streams_through_svc_as_the_app_sends_it_20_mb_whole(monkeypatch):
160
+ async with _computer(monkeypatch) as (app, url):
161
+ async with httpx.AsyncClient(base_url=url, headers=client.control_headers(TOKEN), timeout=30) as http:
162
+ async with http.stream("GET", "/svc/echo/big") as answer:
163
+ chunks, first = answer.aiter_raw(), []
164
+
165
+ async def first_mb() -> None:
166
+ while sum(map(len, first)) < MB:
167
+ first.append(await chunks.__anext__())
168
+ await asyncio.wait_for(first_mb(), 10)
169
+ app.released.set()
170
+ body = b"".join(first) + b"".join([chunk async for chunk in chunks])
171
+
172
+ assert answer.status_code == 200 and len(body) == 20 * MB
173
+ assert body[:MB] == b"a" * MB and body[MB:] == b"b" * (19 * MB)
174
+
175
+
176
+ @pytest.mark.asyncio
177
+ async def test_every_method_and_body_goes_on_and_headers_pass_but_the_hop_ones_token_and_forwarded_address(
178
+ monkeypatch):
179
+ payload = b"x" * (3 * MB)
180
+ async with _computer(monkeypatch) as (_, url):
181
+ async with httpx.AsyncClient(base_url=url, headers=client.control_headers(TOKEN), timeout=30) as http:
182
+ seen = await http.get("/svc/echo/headers", headers={
183
+ "x-forwarded-host": "evil.test", "x-forwarded-prefix": "/elsewhere", "connection": "x-drop",
184
+ "x-drop": "1", "x-kept": "1"})
185
+ put = (await http.put("/svc/echo/upload", content=payload)).json()
186
+ deleted = (await http.delete("/svc/echo/upload")).json()
187
+ refused = await httpx.AsyncClient(base_url=url).get("/svc/echo/headers")
188
+ utf8 = (await http.get("/svc/echo/headers", headers=[(b"x-name", "报告".encode())])).json()["headers"]
189
+ raw = (await http.get("/svc/echo/raw/a%3Fb%2Fc?q=1")).json()
190
+ escaped = (await http.get(httpx.URL(url, raw_path=b"/%73vc/echo/raw/a%3Fb?q=1"))).json()
191
+ http.headers.clear()
192
+ bare = (await http.get("/svc/echo/headers", headers=client.control_headers(TOKEN))).json()["headers"]
193
+ got, host = seen.json()["headers"], url.removeprefix("http://")
194
+
195
+ assert seen.json()["host"] == host and got["x-kept"] == "1"
196
+ assert {key: got[key] for key in svc.FORWARDED} == {
197
+ "x-forwarded-host": host, "x-forwarded-proto": "http", "x-forwarded-prefix": "/svc/echo"}
198
+ assert "authorization" not in got and "x-drop" not in got
199
+ assert seen.headers.get_list("set-cookie") == ["a=1", "b=2"] and "x-secret" not in seen.headers
200
+ assert seen.headers["x-kept"] == "1"
201
+ assert put == {"method": "PUT", "bytes": 3 * MB, "sha256": hashlib.sha256(payload).hexdigest()}
202
+ assert deleted["method"] == "DELETE" and deleted["bytes"] == 0 and refused.status_code == 401
203
+ assert not {"accept", "accept-encoding", "user-agent"} & set(bare)
204
+ assert (b"content-disposition", 'attachment; filename="报告.zip"'.encode()) in seen.headers.raw
205
+ assert utf8["x-name"] == "报告".encode().decode("latin-1")
206
+ assert raw == {"path": "/raw/a%3Fb%2Fc", "query": "q=1"} and escaped == {"path": "/raw/a%3Fb", "query": "q=1"}
207
+
208
+
209
+ @pytest.mark.parametrize(("raw", "root", "routed"), [(b"/svc/echo/raw/a%2Fb", "", "raw/a/b"),
210
+ (b"/gw/svc/echo/raw/a%2Fb", "/gw", "raw/a/b")])
211
+ def test_the_apps_path_is_the_raw_one_past_svc_and_its_name_wherever_the_driver_is_mounted(raw, root, routed):
212
+ connection = SimpleNamespace(scope={"raw_path": raw, "root_path": root, "query_string": b"q=1"},
213
+ path_params={"name": "echo", "path": routed})
214
+
215
+ assert svc.target(connection) == "/raw/a%2Fb?q=1"
216
+
217
+
218
+ async def _handshake(url: str, host: str) -> bytes:
219
+ """The status line the driver answers a WebSocket handshake for the app's echo route with, sent with ``host`` as
220
+ its Host and the control token."""
221
+ reader, writer = await asyncio.open_connection(*url.removeprefix("http://").split(":"))
222
+ writer.write((f"GET /svc/echo/echo HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
223
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n"
224
+ f"Authorization: Bearer {TOKEN}\r\n\r\n").encode())
225
+ status = (await asyncio.wait_for(reader.readline(), 10)).strip()
226
+ writer.close()
227
+ return status
@@ -782,7 +782,7 @@ async def test_its_page_capture_names_the_tab_it_captured_though_another_becomes
782
782
 
783
783
 
784
784
  @pytest.mark.asyncio
785
- async def test_while_a_dialog_keeps_its_page_from_painting_its_capture_is_the_last_it_took():
785
+ async def test_while_a_dialog_keeps_its_page_from_painting_its_capture_is_the_last_it_took_without_waiting_on_it():
786
786
  devtools = Tabbed()
787
787
  control = await _tabs(devtools, "a")
788
788
  before = await control.capture()
@@ -790,6 +790,7 @@ async def test_while_a_dialog_keeps_its_page_from_painting_its_capture_is_the_la
790
790
  devtools.send("Page.javascriptDialogOpening", {"type": "alert", "message": "Saved"}, session="s-a")
791
791
 
792
792
  assert before == "png of s-a" and await control.capture() == "png of s-a"
793
+ assert len(devtools.timeouts) == 1
793
794
 
794
795
 
795
796
  @pytest.mark.asyncio