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.
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/PKG-INFO +13 -2
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/README.md +10 -1
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/pyproject.toml +5 -3
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/agent_env_environment.py +13 -4
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/apps.py +1 -10
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/driver.py +21 -13
- agentenv_framework_protocol-0.1.449/src/agentenv_protocol/computer/svc.py +182 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/website_browser.py +3 -0
- agentenv_framework_protocol-0.1.449/tests/test_computer_svc_forward.py +227 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_website_browser.py +2 -1
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/_declare.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/_linux.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/_load.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/_with_fds.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/app.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/app_data.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/app_runtime.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/card.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/checks.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/client.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/conformance/report.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/errors.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/frames.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/keys.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/launch.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/operations.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/platform.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/queue.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/recording.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/results.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/run_install_step.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/runtime.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/seed.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/serve.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/serve_app.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/session.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/settings.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/skill.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/spaces.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/computer/tools.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/errors.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/computer_fake.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/probe_fake.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_cdp_config.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_proxy.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_app_data.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_app_server.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_conformance.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_conformance_checks.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_conformance_page.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_conformance_recording.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_declare.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_driver.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_extensions.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_frames.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_page.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_queue.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_recording.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_seed.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_serve.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_computer_spaces.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_control_token.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_data_objects.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_errors.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_extension_methods.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_preflight.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_annotations.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_tool_context_fastmcp.py +0 -0
- {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.
|
|
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.
|
|
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,
|
|
45
|
-
#
|
|
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
|
-
|
|
1554
|
-
if not token:
|
|
1563
|
+
if not os.environ.get(CONTROL_TOKEN_ENV):
|
|
1555
1564
|
return lambda handler: handler
|
|
1556
|
-
|
|
1565
|
+
allowed = control_check()
|
|
1557
1566
|
|
|
1558
1567
|
def guard(handler: Callable) -> Callable:
|
|
1559
1568
|
async def guarded(request: Request) -> Response:
|
|
1560
|
-
if not
|
|
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>/``,
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/THIRD_PARTY_NOTICES.md
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/examples/basic_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/computer_fake.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/probe_fake.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_a2a_proxy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_errors.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_intake.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_manifest.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.449}/tests/test_preflight.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|