agentenv-framework-protocol 0.1.468__tar.gz → 0.1.469__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.468 → agentenv_framework_protocol-0.1.469}/PKG-INFO +26 -1
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/README.md +25 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/README.md +9 -5
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/pyproject.toml +1 -1
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website/conformance.py +161 -26
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website/environment.py +30 -6
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website/routes.py +3 -2
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website/session.py +27 -3
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_website_conformance.py +79 -18
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_website_environment.py +44 -6
- agentenv_framework_protocol-0.1.469/tests/test_website_mcp.py +234 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_website_notes_example.py +6 -4
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/Dockerfile +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/Dockerfile.dockerignore +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/data/seed.json +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/env.toml +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/site.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/web/index.html +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/web/package-lock.json +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/web/package.json +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/web/src/main.js +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/web/src/style.css +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/examples/website/notes/web/vite.config.js +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/browser_cdp.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/_declare.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/_linux.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/_load.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/_macos.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/_with_fds.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/app.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/app_data.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/app_runtime.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/apps.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/card.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/checks.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/conformance/client.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/conformance/report.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/driver.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/errors.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/frames.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/keys.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/launch.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/operations.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/platform.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/queue.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/recording.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/results.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/run_install_step.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/runtime.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/seed.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/serve.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/serve_app.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/session.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/settings.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/skill.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/spaces.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/svc.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/computer/tools.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/errors.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website/clock.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website/errors.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website/readiness.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website_browser/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website_browser/control.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website_browser/devtools.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website_browser/environment.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/src/agentenv_protocol/website_browser/shapes.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/computer_fake.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/probe_fake.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_a2a_cdp_config.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_a2a_peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_a2a_proxy.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_a2a_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_browser_cdp.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_browser_state.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_app_data.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_app_server.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_conformance.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_conformance_checks.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_conformance_page.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_conformance_recording.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_declare.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_driver.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_extensions.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_frames.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_macos.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_page.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_queue.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_recording.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_seed.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_serve.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_spaces.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_computer_svc_forward.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_control_token.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_data_objects.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_errors.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_extension_methods.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_preflight.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_tool_annotations.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_tool_context_fastmcp.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_tool_context_injection.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_website_api.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_website_browser.py +0 -0
- {agentenv_framework_protocol-0.1.468 → agentenv_framework_protocol-0.1.469}/tests/test_website_readiness.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.469
|
|
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
|
|
@@ -524,6 +524,31 @@ print(env.ready, env.problems) # False ['shop: seeded']
|
|
|
524
524
|
asked, or answers no `WebsiteReadiness`, is a problem itself. A card or child without `ready`, an MCP server's or an
|
|
525
525
|
older site's, isn't counted, so an env with no site serving it is ready with no problems.
|
|
526
526
|
|
|
527
|
+
## Websites with tools
|
|
528
|
+
|
|
529
|
+
A website env may also be an MCP server, so an agent can act on a site through tools as well as its pages. Give a
|
|
530
|
+
`WebsiteEnvironment` `@tool` (or `@resource`) methods, as an MCP env has; they need the `mcp` package. The site then
|
|
531
|
+
serves them at `/mcp` (streamable HTTP) beside its pages, `@api` routes and data plane, and its card declares that
|
|
532
|
+
endpoint as its `mcp` interface (`additionalInterfaces`) and lists the tools under `capabilities.tools`. A site without
|
|
533
|
+
tools serves no `/mcp` and declares no interface; `serves_mcp()` says which a site is.
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
@environment_card(name="shop")
|
|
537
|
+
@website(domains=["shop.example"], static="dist")
|
|
538
|
+
class Shop(WebsiteEnvironment):
|
|
539
|
+
@tool()
|
|
540
|
+
def place_order(self, item: str, ctx: ToolContext) -> str:
|
|
541
|
+
"""Place an order for item, as the caller."""
|
|
542
|
+
...
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
A tool runs in the site's process, so it changes what its pages show and what `data/get` returns, and it reads the
|
|
546
|
+
universe's time (`self.now()`) as a page's request does. The gateway reads each site's card when it starts, and serves
|
|
547
|
+
the tools of a site whose card declares an `mcp` interface as it does an MCP env's: listed to agents with the rest,
|
|
548
|
+
called with the caller's role in `ToolContext`. The site's `/mcp` is one of the paths its card advertises, which its
|
|
549
|
+
browsing route (`/website/<name>/`) never reaches. `python -m agentenv_protocol.website.conformance` checks a declared
|
|
550
|
+
endpoint (`mcp`): it answers `initialize` and lists the tools the card does.
|
|
551
|
+
|
|
527
552
|
## Computer drivers
|
|
528
553
|
|
|
529
554
|
A computer driver is what runs inside a computer an agent uses through screenshots and input. Write
|
|
@@ -462,6 +462,31 @@ print(env.ready, env.problems) # False ['shop: seeded']
|
|
|
462
462
|
asked, or answers no `WebsiteReadiness`, is a problem itself. A card or child without `ready`, an MCP server's or an
|
|
463
463
|
older site's, isn't counted, so an env with no site serving it is ready with no problems.
|
|
464
464
|
|
|
465
|
+
## Websites with tools
|
|
466
|
+
|
|
467
|
+
A website env may also be an MCP server, so an agent can act on a site through tools as well as its pages. Give a
|
|
468
|
+
`WebsiteEnvironment` `@tool` (or `@resource`) methods, as an MCP env has; they need the `mcp` package. The site then
|
|
469
|
+
serves them at `/mcp` (streamable HTTP) beside its pages, `@api` routes and data plane, and its card declares that
|
|
470
|
+
endpoint as its `mcp` interface (`additionalInterfaces`) and lists the tools under `capabilities.tools`. A site without
|
|
471
|
+
tools serves no `/mcp` and declares no interface; `serves_mcp()` says which a site is.
|
|
472
|
+
|
|
473
|
+
```python
|
|
474
|
+
@environment_card(name="shop")
|
|
475
|
+
@website(domains=["shop.example"], static="dist")
|
|
476
|
+
class Shop(WebsiteEnvironment):
|
|
477
|
+
@tool()
|
|
478
|
+
def place_order(self, item: str, ctx: ToolContext) -> str:
|
|
479
|
+
"""Place an order for item, as the caller."""
|
|
480
|
+
...
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
A tool runs in the site's process, so it changes what its pages show and what `data/get` returns, and it reads the
|
|
484
|
+
universe's time (`self.now()`) as a page's request does. The gateway reads each site's card when it starts, and serves
|
|
485
|
+
the tools of a site whose card declares an `mcp` interface as it does an MCP env's: listed to agents with the rest,
|
|
486
|
+
called with the caller's role in `ToolContext`. The site's `/mcp` is one of the paths its card advertises, which its
|
|
487
|
+
browsing route (`/website/<name>/`) never reaches. `python -m agentenv_protocol.website.conformance` checks a declared
|
|
488
|
+
endpoint (`mcp`): it answers `initialize` and lists the tools the card does.
|
|
489
|
+
|
|
465
490
|
## Computer drivers
|
|
466
491
|
|
|
467
492
|
A computer driver is what runs inside a computer an agent uses through screenshots and input. Write
|
|
@@ -22,9 +22,11 @@ them. The page shows the universe's time.
|
|
|
22
22
|
sends `AgentEnv-Role: admin` with `AgentEnv-Role-Key` equal to the container's `WEBSITE_ROLE_KEY`; any other caller
|
|
23
23
|
gets 403. The gateway passes on whatever role a browser sends, so without the key a page could claim `admin`; with no
|
|
24
24
|
`WEBSITE_ROLE_KEY` set, every caller is `default` and the route is closed.
|
|
25
|
-
- **Users.** `ApiContext.user` is the user in the signed session cookie
|
|
26
|
-
|
|
27
|
-
user
|
|
25
|
+
- **Users.** `ApiContext.user` is the user in the signed session cookie, `agentenv_session_notes` (the site's own, so
|
|
26
|
+
it doesn't collide with another site's under the same gateway host; the card names it). With no cookie, it is the
|
|
27
|
+
acting user (the data's `current_user`, which `acting_user()` returns), and `GET /api/session` signs that user in.
|
|
28
|
+
`POST /api/sign-in` switches to another seeded user. A user's notes are invisible to every other user, and another
|
|
29
|
+
user's note answers 404.
|
|
28
30
|
- **State.** The site keeps its users and notes itself, starting from `data/seed.json`, and serves them through its
|
|
29
31
|
data hooks: `data/add` adds a part's records by id (a data part, or a file part by bytes or `file://`), `data/get`
|
|
30
32
|
exports them, and `data/reset` empties them. `data/get` is how agentenv reads what a task left the site with.
|
|
@@ -65,11 +67,13 @@ the test above runs this repo's.
|
|
|
65
67
|
```bash
|
|
66
68
|
docker build -t notes-site .
|
|
67
69
|
docker run -d --name notes-site -p 18080:8000 notes-site
|
|
68
|
-
python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal
|
|
70
|
+
python -m agentenv_protocol.website.conformance http://localhost:18080 --clock-host host.docker.internal --strict
|
|
69
71
|
curl -s http://localhost:18080/api/notes
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
Use `--clock-host` so the container can reach the checker's clock.
|
|
74
|
+
Use `--clock-host` so the container can reach the checker's clock. `--strict` fails on a recommended check's warning
|
|
75
|
+
too (the card, `X-Universe-Now`, the clock, forwarded headers, cookies); without it only the contract's required checks
|
|
76
|
+
fail the run, and Notes passes every check either way. The clock check leaves the server following a
|
|
73
77
|
clock that is gone once the check ends: later timestamps start from 2031 until the server is synced again, so restart
|
|
74
78
|
the container before using it.
|
|
75
79
|
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in:
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in: ``python -m
|
|
2
|
+
agentenv_protocol.website.conformance <url>`` prints each check and exits 1 when a required one failed (or, with
|
|
3
|
+
``--strict``, when a recommended one warned), 2 when the server couldn't be reached. Everything goes over HTTP.
|
|
4
|
+
|
|
5
|
+
The required checks are the contract, what agentenv, the gateway and a browser rely on of a website env's server: a
|
|
6
|
+
card declaring the site's domains, readiness and routes; ``/`` serving HTML under a made-up host; the site ready, by
|
|
7
|
+
the card's ``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` (by the declared health path,
|
|
8
|
+
deprecated, for a card without it); the data plane's and card's paths not the site's pages; and the data plane the
|
|
9
|
+
card advertises, whose round trip at the card's ``url`` (data/get, data/reset, data/get, which no longer returns the
|
|
10
|
+
exported data, data/add of what the first data/get returned, data/get again) returns what it was given; and, for a
|
|
11
|
+
card declaring an ``mcp`` interface, a site that is an MCP server too, that endpoint answering ``initialize`` and
|
|
12
|
+
listing, every page of ``tools/list`` read, no tool its card doesn't (streamable HTTP, ``MCP_PROTOCOL_VERSION``); a
|
|
13
|
+
card's tool it doesn't list is warned, since a hidden-by-default tool is listed to no caller until one enables it.
|
|
14
|
+
A failed one is ``failed``. The recommended checks are what an SDK site does and the gateway uses when a server offers it:
|
|
15
|
+
``X-Universe-Now`` on every answer the other checks got, ``clock/v1``'s ``sync_time`` against a clock the checker serves, the forwarded headers
|
|
16
|
+
wherever an answer shows them (on ``/`` and on a GET route with its trailing slash toggled, which a server may
|
|
17
|
+
redirect), and the cookies those answers set: none tied to a domain, the session path's the one the card names, and
|
|
18
|
+
none whose lifetime is an absolute ``Expires`` with no ``Max-Age``. A failed one is ``warned``.
|
|
11
19
|
|
|
12
20
|
Every request goes to the server under check: a card's path or URL naming another scheme, host or port fails the check
|
|
13
21
|
that would have used it, with nothing sent there. The round trip leaves the server's data as it found it, adding the
|
|
@@ -33,25 +41,35 @@ import httpx
|
|
|
33
41
|
from pydantic import ValidationError
|
|
34
42
|
|
|
35
43
|
from ..client import find_extension, find_extension_method
|
|
36
|
-
from ..types import METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH
|
|
44
|
+
from ..types import MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH
|
|
37
45
|
from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time
|
|
38
46
|
from .readiness import DEFAULT_HEALTH_PATH, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness
|
|
39
47
|
|
|
40
48
|
Status = Literal["passed", "failed", "skipped", "warned"]
|
|
41
49
|
CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc)
|
|
42
50
|
CLOCK_SLACK_S = 120
|
|
51
|
+
MCP_PROTOCOL_VERSION = "2025-06-18"
|
|
52
|
+
MCP_LIST_PAGES = 100
|
|
43
53
|
|
|
44
54
|
|
|
45
55
|
@dataclasses.dataclass(frozen=True)
|
|
46
56
|
class Result:
|
|
57
|
+
"""One check's outcome; ``required`` for a check of the contract, whose failure is ``failed``, and not for a
|
|
58
|
+
recommended one, whose failure is ``warned``."""
|
|
59
|
+
|
|
47
60
|
name: str
|
|
48
61
|
status: Status
|
|
49
62
|
detail: str = ""
|
|
63
|
+
required: bool = True
|
|
50
64
|
|
|
51
65
|
@property
|
|
52
66
|
def passed(self) -> bool:
|
|
53
67
|
return self.status != "failed"
|
|
54
68
|
|
|
69
|
+
def fails(self, strict: bool = False) -> bool:
|
|
70
|
+
"""Whether the result fails the run: a failed check, or with ``strict`` a warned one too."""
|
|
71
|
+
return self.status == "failed" or (strict and self.status == "warned")
|
|
72
|
+
|
|
55
73
|
|
|
56
74
|
def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1",
|
|
57
75
|
timeout: float = 30) -> list[Result]:
|
|
@@ -92,19 +110,27 @@ class _Checker:
|
|
|
92
110
|
card = response.json() if response.status_code == 200 else {}
|
|
93
111
|
except ValueError:
|
|
94
112
|
card = {}
|
|
113
|
+
card = card if isinstance(card, dict) else {}
|
|
95
114
|
site = (find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {}
|
|
96
115
|
health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
("
|
|
100
|
-
("
|
|
101
|
-
|
|
116
|
+
# (name, required, check): a recommended check's failure is warned.
|
|
117
|
+
steps = (("card", True, lambda: self._card(response, card, site)), ("any_host", True, self._any_host),
|
|
118
|
+
("health", True, lambda: self._health(card, health)), ("universe_now", False, self._universe_now),
|
|
119
|
+
("reserved_paths", True, self._reserved_paths), ("data_plane", True, lambda: self._data_plane(card)),
|
|
120
|
+
("mcp", True, lambda: self._mcp(card)),
|
|
121
|
+
("clock", False, lambda: self._clock(card)), ("forwarded", False, lambda: self._forwarded(site)),
|
|
122
|
+
("cookies", False, lambda: self._cookies(site)))
|
|
123
|
+
outcomes = {name: _outcome(step) for name, _required, step in steps if name != "universe_now"}
|
|
102
124
|
outcomes["universe_now"] = _outcome(self._universe_now)
|
|
103
|
-
|
|
125
|
+
results = []
|
|
126
|
+
for name, required, _step in steps:
|
|
127
|
+
status, detail = outcomes[name]
|
|
128
|
+
results.append(Result(name, "warned" if status == "failed" and not required else status, detail, required))
|
|
129
|
+
return results
|
|
104
130
|
|
|
105
|
-
def _card(self, response: httpx.Response, card: dict, site: dict) ->
|
|
131
|
+
def _card(self, response: httpx.Response, card: dict, site: dict) -> tuple[Status, str]:
|
|
106
132
|
if response.status_code != 200:
|
|
107
|
-
return
|
|
133
|
+
return "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}"
|
|
108
134
|
problems = []
|
|
109
135
|
if not site:
|
|
110
136
|
problems.append(f"declares no {WEBSITE_EXTENSION_URI} with params")
|
|
@@ -117,7 +143,7 @@ class _Checker:
|
|
|
117
143
|
if not isinstance(routes, list) or not all(isinstance(r, dict) and {"method", "path", "name"} <= set(r)
|
|
118
144
|
for r in routes):
|
|
119
145
|
problems.append("its params' routes aren't a list of {method, path, name}")
|
|
120
|
-
return
|
|
146
|
+
return ("failed" if problems else "passed"), "; ".join(problems)
|
|
121
147
|
|
|
122
148
|
def _any_host(self) -> tuple[Status, str]:
|
|
123
149
|
host = f"conformance-{uuid.uuid4().hex[:8]}.invalid"
|
|
@@ -159,6 +185,15 @@ class _Checker:
|
|
|
159
185
|
parse_time(response.headers[UNIVERSE_NOW_HEADER])
|
|
160
186
|
return "passed", f"on all {len(self.responses)} answers"
|
|
161
187
|
|
|
188
|
+
def _reserved_paths(self) -> tuple[Status, str]:
|
|
189
|
+
paths = [RPC_PATH, WELL_KNOWN_PATH, f"{RPC_PATH}/conformance-{uuid.uuid4().hex[:8]}", f"{RPC_PATH}/",
|
|
190
|
+
f"{WELL_KNOWN_PATH}/"]
|
|
191
|
+
for path in paths:
|
|
192
|
+
response = self.get(path)
|
|
193
|
+
if response.is_success and response.headers.get("content-type", "").startswith("text/html"):
|
|
194
|
+
return "failed", f"{path} answered a page, but the gateway keeps {RPC_PATH} and {WELL_KNOWN_PATH}"
|
|
195
|
+
return "passed", f"no page at {', '.join(paths)}"
|
|
196
|
+
|
|
162
197
|
def _rpc(self, method: str, params: dict | None = None) -> dict:
|
|
163
198
|
response = self._seen(self.client.post(self.rpc_url, json={"jsonrpc": "2.0", "id": method,
|
|
164
199
|
"method": method, "params": params or {}}))
|
|
@@ -171,7 +206,8 @@ class _Checker:
|
|
|
171
206
|
|
|
172
207
|
def _data_plane(self, card: dict) -> tuple[Status, str]:
|
|
173
208
|
operations = (card.get("capabilities") or {}).get("operations")
|
|
174
|
-
|
|
209
|
+
operations = operations if isinstance(operations, list) else []
|
|
210
|
+
missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if m not in operations]
|
|
175
211
|
if missing:
|
|
176
212
|
return "failed", f"the card's operations lack {', '.join(missing)}"
|
|
177
213
|
url = card.get("url")
|
|
@@ -198,6 +234,55 @@ class _Checker:
|
|
|
198
234
|
return "failed", "data/get after data/reset and data/add of its own export returned other data"
|
|
199
235
|
return "passed", f"{len(exported)} part(s) round-tripped through reset and add"
|
|
200
236
|
|
|
237
|
+
def _mcp(self, card: dict) -> tuple[Status, str]:
|
|
238
|
+
"""The MCP endpoint the card declares answers ``initialize`` and lists no tool the card doesn't, reading up to
|
|
239
|
+
``MCP_LIST_PAGES`` pages of ``tools/list``; its answers needn't carry ``X-Universe-Now``, which the MCP
|
|
240
|
+
transport doesn't promise."""
|
|
241
|
+
interfaces = [i for i in card.get("additionalInterfaces") or [] if isinstance(i, dict)
|
|
242
|
+
and i.get("transport") == MCP_TRANSPORT]
|
|
243
|
+
if not interfaces:
|
|
244
|
+
return "skipped", "the card declares no mcp interface"
|
|
245
|
+
path = interfaces[0].get("url")
|
|
246
|
+
if not isinstance(path, str) or not path:
|
|
247
|
+
return "failed", f"the card's mcp interface has no url: {path!r}"
|
|
248
|
+
url = self._url(path)
|
|
249
|
+
headers = {"Accept": "application/json, text/event-stream"}
|
|
250
|
+
first = self.client.post(url, headers=headers, json={
|
|
251
|
+
"jsonrpc": "2.0", "id": "initialize", "method": "initialize",
|
|
252
|
+
"params": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {},
|
|
253
|
+
"clientInfo": {"name": "agentenv-website-conformance", "version": "1"}}})
|
|
254
|
+
server = _mcp_result(first, "initialize", "initialize").get("serverInfo") or {}
|
|
255
|
+
if session := first.headers.get("mcp-session-id"):
|
|
256
|
+
headers["Mcp-Session-Id"] = session
|
|
257
|
+
try:
|
|
258
|
+
self.client.post(url, headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
259
|
+
served: list = []
|
|
260
|
+
cursor = None
|
|
261
|
+
for _page in range(MCP_LIST_PAGES):
|
|
262
|
+
listed = _mcp_result(self.client.post(url, headers=headers, json={
|
|
263
|
+
"jsonrpc": "2.0", "id": "tools/list", "method": "tools/list",
|
|
264
|
+
**({"params": {"cursor": cursor}} if cursor else {})}), "tools/list", "tools/list")
|
|
265
|
+
if not isinstance(listed, dict) or not isinstance(listed.get("tools"), list):
|
|
266
|
+
return "failed", f"tools/list at {path} answered no tools list: {str(listed)[:200]}"
|
|
267
|
+
served += [t.get("name") for t in listed["tools"] if isinstance(t, dict)]
|
|
268
|
+
cursor = listed.get("nextCursor")
|
|
269
|
+
if not cursor:
|
|
270
|
+
break
|
|
271
|
+
else:
|
|
272
|
+
return "failed", f"tools/list at {path} still had a next page after {MCP_LIST_PAGES}"
|
|
273
|
+
finally:
|
|
274
|
+
if session:
|
|
275
|
+
with contextlib.suppress(httpx.HTTPError):
|
|
276
|
+
self.client.delete(url, headers=headers)
|
|
277
|
+
advertised = {t.get("name") for t in (card.get("capabilities") or {}).get("tools") or [] if isinstance(t, dict)}
|
|
278
|
+
if unadvertised := sorted(set(served) - advertised):
|
|
279
|
+
return "failed", f"tools/list at {path} named {unadvertised}, which the card doesn't list"
|
|
280
|
+
name = server.get("name") or "the server"
|
|
281
|
+
if unlisted := sorted(advertised - set(served)):
|
|
282
|
+
return "warned", (f"{name} at {path} lists {len(served)} of the card's tools, not {unlisted}: hidden by "
|
|
283
|
+
"default, or missing")
|
|
284
|
+
return "passed", f"{name} at {path} lists the card's {len(served)} tool(s)"
|
|
285
|
+
|
|
201
286
|
def _clock(self, card: dict) -> tuple[Status, str]:
|
|
202
287
|
if find_extension(card, CLOCK_EXTENSION_URI) is None:
|
|
203
288
|
return "failed", f"the card declares no {CLOCK_EXTENSION_URI}"
|
|
@@ -238,14 +323,19 @@ class _Checker:
|
|
|
238
323
|
return "passed", f"no answer to {', '.join(paths)} named the internal Host"
|
|
239
324
|
|
|
240
325
|
def _cookies(self, site: dict) -> tuple[Status, str]:
|
|
326
|
+
session = []
|
|
241
327
|
if isinstance(site.get("session_path"), str):
|
|
242
|
-
self.get(site["session_path"])
|
|
328
|
+
session = self.get(site["session_path"]).headers.get_list("set-cookie")
|
|
243
329
|
cookies = [c for r in self.responses for c in r.headers.get_list("set-cookie")]
|
|
244
330
|
if not cookies:
|
|
245
331
|
return "skipped", "no answer set a cookie"
|
|
246
332
|
scoped = sorted({c.split("=", 1)[0] for c in cookies if "domain" in _attributes(c)})
|
|
247
333
|
if scoped:
|
|
248
334
|
return "failed", f"cookies tied to a domain: {', '.join(scoped)}"
|
|
335
|
+
named = site.get("session_cookie")
|
|
336
|
+
if isinstance(named, str) and session and named not in {c.split("=", 1)[0].strip() for c in session}:
|
|
337
|
+
return "failed", (f"{site['session_path']} set {', '.join(c.split('=', 1)[0] for c in session)}, not the "
|
|
338
|
+
f"card's {named}")
|
|
249
339
|
absolute = sorted({c.split("=", 1)[0] for c in cookies if "expires" in _attributes(c) and "max-age" not in _attributes(c)})
|
|
250
340
|
if absolute:
|
|
251
341
|
return "warned", (f"cookies with an absolute Expires and no Max-Age, which a browser on another clock "
|
|
@@ -275,6 +365,48 @@ def _outcome(step: Callable[[], tuple[Status, str]]) -> tuple[Status, str]:
|
|
|
275
365
|
return "failed", f"{type(e).__name__}: {e}"
|
|
276
366
|
|
|
277
367
|
|
|
368
|
+
def _mcp_result(response: httpx.Response, method: str, request_id: str) -> dict:
|
|
369
|
+
"""The result of the JSON-RPC reply to request ``request_id``: the answer's JSON, or the reply among an event
|
|
370
|
+
stream's messages, each event's data lines joined, as ``ToolSession`` reads them."""
|
|
371
|
+
if response.headers.get("content-type", "").startswith("text/event-stream"):
|
|
372
|
+
messages = []
|
|
373
|
+
for data in _sse_data(response.text):
|
|
374
|
+
try:
|
|
375
|
+
messages.append(json.loads(data))
|
|
376
|
+
except ValueError:
|
|
377
|
+
raise RuntimeError(f"{method} answered an event that isn't JSON: {data[:200]!r}") from None
|
|
378
|
+
else:
|
|
379
|
+
try:
|
|
380
|
+
messages = [json.loads(response.text)]
|
|
381
|
+
except ValueError:
|
|
382
|
+
raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC answer: "
|
|
383
|
+
f"{response.text[:200]!r}") from None
|
|
384
|
+
body = next((m for m in messages if isinstance(m, dict) and m.get("id") == request_id
|
|
385
|
+
and ("result" in m or "error" in m)), None)
|
|
386
|
+
if body is None or "result" not in body:
|
|
387
|
+
raise RuntimeError(f"{method} answered {response.status_code}: {str(body or messages)[:200]}")
|
|
388
|
+
return body["result"] or {}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _sse_data(text: str) -> Iterator[str]:
|
|
392
|
+
"""The data of each ``message`` event in a server-sent event stream, its data lines joined; an event the stream
|
|
393
|
+
ends inside is dropped, as are events with no data."""
|
|
394
|
+
event, data = "message", []
|
|
395
|
+
for line in text.splitlines():
|
|
396
|
+
if not line:
|
|
397
|
+
payload = "\n".join(data)
|
|
398
|
+
if payload.strip() and event == "message":
|
|
399
|
+
yield payload
|
|
400
|
+
event, data = "message", []
|
|
401
|
+
continue
|
|
402
|
+
field, _, value = line.partition(":")
|
|
403
|
+
value = value[1:] if value.startswith(" ") else value
|
|
404
|
+
if field == "data":
|
|
405
|
+
data.append(value)
|
|
406
|
+
elif field == "event":
|
|
407
|
+
event = value
|
|
408
|
+
|
|
409
|
+
|
|
278
410
|
def _field_error(error: dict) -> str:
|
|
279
411
|
"""One of a ``ValidationError``'s errors as a person reads it: where, then what."""
|
|
280
412
|
where = ".".join(map(str, error.get("loc") or ())) or "the answer"
|
|
@@ -341,6 +473,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
341
473
|
parser.add_argument("url", help="where the server answers, such as http://localhost:8000")
|
|
342
474
|
parser.add_argument("--clock-host", default="127.0.0.1", help="the address the server reaches this checker at")
|
|
343
475
|
parser.add_argument("--timeout", type=float, default=30, help="seconds for each request")
|
|
476
|
+
parser.add_argument("--strict", action="store_true", help="fail on a recommended check's warning too")
|
|
344
477
|
args = parser.parse_args(argv)
|
|
345
478
|
try:
|
|
346
479
|
results = check(args.url, clock_host=args.clock_host, timeout=args.timeout)
|
|
@@ -348,10 +481,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
348
481
|
print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr)
|
|
349
482
|
return 2
|
|
350
483
|
for result in results:
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
484
|
+
kind = "required" if result.required else "recommended"
|
|
485
|
+
print(f"{_MARK[result.status]} {result.name} ({kind}){': ' + result.detail if result.detail else ''}")
|
|
486
|
+
counts = {status: sum(r.status == status for r in results) for status in _MARK}
|
|
487
|
+
print(f"{args.url}: {counts['passed']} passed, {counts['failed']} failed, {counts['warned']} warned, "
|
|
488
|
+
f"{counts['skipped']} skipped{' (strict)' if args.strict else ''}")
|
|
489
|
+
return 1 if any(r.fails(args.strict) for r in results) else 0
|
|
355
490
|
|
|
356
491
|
|
|
357
492
|
if __name__ == "__main__":
|
|
@@ -8,6 +8,10 @@ usually its schema of the env's database (``database_url``), and serves it throu
|
|
|
8
8
|
(``@reset_data``, ``@add_data``, ``@get_data``) as any AgentEnvEnvironment does: ``data/get`` is how agentenv reads
|
|
9
9
|
what a task left a site with.
|
|
10
10
|
|
|
11
|
+
A site may also be an MCP server, as an MCP env is: its ``@tool`` and ``@resource`` methods are served at ``/mcp``
|
|
12
|
+
(streamable HTTP, of the ``mcp`` package), which its card declares as its ``mcp`` interface and lists the tools of, so
|
|
13
|
+
an agent can act on the site through tools beside its pages. A site with none serves no ``/mcp``.
|
|
14
|
+
|
|
11
15
|
One route is built in, replaced by a site's own ``@api`` route at its very path and answering before one whose path
|
|
12
16
|
parameters would also match it: ``/api/session``, which a frontend calls to learn who is signed in, signing the acting
|
|
13
17
|
user in when nobody is (GET), or to sign out (DELETE)."""
|
|
@@ -32,9 +36,12 @@ from starlette.routing import Route
|
|
|
32
36
|
|
|
33
37
|
from ..agent_env_environment import (
|
|
34
38
|
AgentEnvEnvironment,
|
|
39
|
+
_RESOURCE_ATTR,
|
|
40
|
+
_TOOL_ATTR,
|
|
35
41
|
AgentEnvStarletteApplication,
|
|
36
42
|
_marked_members,
|
|
37
43
|
_maybe_await,
|
|
44
|
+
create_fastmcp_app,
|
|
38
45
|
extension,
|
|
39
46
|
)
|
|
40
47
|
from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, ClockUnavailable, UniverseClock, clock_patch_script, iso
|
|
@@ -42,7 +49,7 @@ from .errors import ApiError, NotFound
|
|
|
42
49
|
from .readiness import SITE_READY_TIMEOUT_S, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness
|
|
43
50
|
from .routes import (ApiContext, ApiRoute, endpoint, error_response, forwarded_prefix, openapi, openapi_path, request_role,
|
|
44
51
|
reserved_path, routes_of)
|
|
45
|
-
from .session import
|
|
52
|
+
from .session import SessionSigner, session_cookie_name
|
|
46
53
|
|
|
47
54
|
SESSION_PATH = "/api/session"
|
|
48
55
|
CHECK_TIMEOUT_S = 10.0
|
|
@@ -114,7 +121,7 @@ class WebsiteEnvironment(AgentEnvEnvironment):
|
|
|
114
121
|
|
|
115
122
|
def __init__(self) -> None:
|
|
116
123
|
self.clock = UniverseClock()
|
|
117
|
-
self._signer = SessionSigner()
|
|
124
|
+
self._signer = SessionSigner(cookie_name=session_cookie_name(self._build_card().name))
|
|
118
125
|
self._started = time.monotonic()
|
|
119
126
|
self._checking: dict[str, asyncio.Future] = {}
|
|
120
127
|
|
|
@@ -126,6 +133,11 @@ class WebsiteEnvironment(AgentEnvEnvironment):
|
|
|
126
133
|
def routes(cls) -> list[ApiRoute]:
|
|
127
134
|
return routes_of(cls)
|
|
128
135
|
|
|
136
|
+
@property
|
|
137
|
+
def session_cookie(self) -> str:
|
|
138
|
+
"""The name of the site's session cookie, ``agentenv_session_<environment_name>``."""
|
|
139
|
+
return self._session_signer().cookie_name
|
|
140
|
+
|
|
129
141
|
@property
|
|
130
142
|
def database_url(self) -> str | None:
|
|
131
143
|
"""Where the site keeps its task state: its own schema of the env's database."""
|
|
@@ -148,7 +160,7 @@ class WebsiteEnvironment(AgentEnvEnvironment):
|
|
|
148
160
|
|
|
149
161
|
def context(self, request: Request) -> ApiContext:
|
|
150
162
|
"""The ``ApiContext`` of ``request``, for a mounted ``app``'s routes as for ``@api`` ones."""
|
|
151
|
-
session = self._session_signer().
|
|
163
|
+
session = self._session_signer().read(request.cookies)
|
|
152
164
|
user = session["user"] if session is not None else self.acting_user()
|
|
153
165
|
return ApiContext(request, user, request_role(request), session, self._session_signer())
|
|
154
166
|
|
|
@@ -159,8 +171,15 @@ class WebsiteEnvironment(AgentEnvEnvironment):
|
|
|
159
171
|
api_routes = self.routes()
|
|
160
172
|
taken = {openapi_path(r.path) for r in api_routes}
|
|
161
173
|
root = self._site_path(declaration.static) if declaration.static is not None else None
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
middleware = [Middleware(_Forwarded), Middleware(_Universe, env=self)]
|
|
175
|
+
if self.serves_mcp():
|
|
176
|
+
self.mcp = create_fastmcp_app(self, card=self._build_card())
|
|
177
|
+
served = self.mcp.streamable_http_app()
|
|
178
|
+
asgi = Starlette(middleware=middleware, lifespan=served.router.lifespan_context)
|
|
179
|
+
asgi.router.routes.extend(served.routes)
|
|
180
|
+
else:
|
|
181
|
+
asgi = Starlette(middleware=middleware)
|
|
182
|
+
AgentEnvStarletteApplication(self._build_card(), self).add_routes_to_app(asgi)
|
|
164
183
|
if SESSION_PATH not in taken:
|
|
165
184
|
asgi.router.routes.append(Route(SESSION_PATH, self._session, methods=["GET", "DELETE"]))
|
|
166
185
|
asgi.router.routes.extend(Route(r.path, _unreserved(endpoint(getattr(self, r.fn.__name__), r, self.context)),
|
|
@@ -170,6 +189,11 @@ class WebsiteEnvironment(AgentEnvEnvironment):
|
|
|
170
189
|
self._mounted = True
|
|
171
190
|
return asgi
|
|
172
191
|
|
|
192
|
+
def serves_mcp(self) -> bool:
|
|
193
|
+
"""Whether the site is an MCP server too: it has ``@tool`` or ``@resource`` methods, or forwards tools."""
|
|
194
|
+
return any(True for _ in _marked_members(self, _TOOL_ATTR)) or any(
|
|
195
|
+
True for _ in _marked_members(self, _RESOURCE_ATTR)) or bool(self.forwarded_tools())
|
|
196
|
+
|
|
173
197
|
def mount(self, app: Any) -> Any:
|
|
174
198
|
raise TypeError(f"{type(self).__name__} builds its own app with create_app(); set app = ... to serve more")
|
|
175
199
|
|
|
@@ -186,7 +210,7 @@ class WebsiteEnvironment(AgentEnvEnvironment):
|
|
|
186
210
|
if uri == WEBSITE_EXTENSION_URI:
|
|
187
211
|
declaration = self.declaration()
|
|
188
212
|
return {"domains": list(declaration.domains),
|
|
189
|
-
"routes": [r.advertised() for r in self.routes()], "session_cookie":
|
|
213
|
+
"routes": [r.advertised() for r in self.routes()], "session_cookie": self.session_cookie,
|
|
190
214
|
"session_path": SESSION_PATH}
|
|
191
215
|
return super().extension_params(uri)
|
|
192
216
|
|
|
@@ -28,7 +28,7 @@ from ..agent_env_environment import _coerce_scalar, _maybe_await, _schema_for_an
|
|
|
28
28
|
from ..tool_context import DEFAULT_ROLE, caller_from_request
|
|
29
29
|
from ..types import RPC_PATH, WELL_KNOWN_PATH
|
|
30
30
|
from .errors import ApiError, BadRequest, Forbidden
|
|
31
|
-
from .session import
|
|
31
|
+
from .session import SESSION_TTL_S, SessionSigner
|
|
32
32
|
|
|
33
33
|
logger = logging.getLogger(__name__)
|
|
34
34
|
|
|
@@ -154,7 +154,8 @@ class ApiContext:
|
|
|
154
154
|
|
|
155
155
|
def apply(self, response: Response) -> Response:
|
|
156
156
|
for value in self._set_cookie:
|
|
157
|
-
response.set_cookie(
|
|
157
|
+
response.set_cookie(self._signer.cookie_name, value, max_age=SESSION_TTL_S, path="/", httponly=True,
|
|
158
|
+
samesite="lax")
|
|
158
159
|
return response
|
|
159
160
|
|
|
160
161
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
under ``WEBSITE_SESSION_SECRET``, which a deploy sets for each site so its sessions outlive its process, or a secret
|
|
3
3
|
of the process's own when that isn't set. The cookie names no domain, so it holds under whatever host the site is
|
|
4
4
|
reached at, and its lifetime is a ``Max-Age``, never an absolute ``Expires``, so the browser's clock and the site's
|
|
5
|
-
needn't agree.
|
|
5
|
+
needn't agree. Its name is the site's own, ``agentenv_session_<name>``, so two sites a browser reaches under one gateway
|
|
6
|
+
host (``/website/a/``, ``/website/b/``) keep a session each."""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
9
|
import base64
|
|
@@ -11,6 +12,7 @@ import hashlib
|
|
|
11
12
|
import hmac
|
|
12
13
|
import json
|
|
13
14
|
import os
|
|
15
|
+
import re
|
|
14
16
|
import secrets
|
|
15
17
|
import time
|
|
16
18
|
|
|
@@ -18,12 +20,30 @@ SESSION_COOKIE = "agentenv_session"
|
|
|
18
20
|
SESSION_SECRET_ENV = "WEBSITE_SESSION_SECRET"
|
|
19
21
|
SESSION_TTL_S = 7 * 24 * 3600
|
|
20
22
|
SECRET_BYTES = 32
|
|
23
|
+
_NOT_TOKEN = re.compile(r"[^A-Za-z0-9_.-]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cookie_suffix(environment_name: str) -> str:
|
|
27
|
+
"""The part of a site's cookie names that is its own: the name itself when a cookie name can hold each of its
|
|
28
|
+
characters, else the name with each such character made ``_`` and ``~`` and a hash of the whole name appended. No
|
|
29
|
+
name kept as itself holds a ``~``, so two distinct names never make one suffix."""
|
|
30
|
+
if not _NOT_TOKEN.search(environment_name):
|
|
31
|
+
return environment_name
|
|
32
|
+
digest = hashlib.sha256(environment_name.encode("utf-8", "surrogatepass")).hexdigest()[:12]
|
|
33
|
+
return f"{_NOT_TOKEN.sub('_', environment_name)}~{digest}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def session_cookie_name(environment_name: str | None) -> str:
|
|
37
|
+
"""The session cookie of the site named ``environment_name``: ``agentenv_session_<name>``, a name a cookie name
|
|
38
|
+
can't hold spelled as ``_cookie_suffix`` spells it; the shared ``agentenv_session`` for a site with no name."""
|
|
39
|
+
return f"{SESSION_COOKIE}_{_cookie_suffix(environment_name)}" if environment_name else SESSION_COOKIE
|
|
21
40
|
|
|
22
41
|
|
|
23
42
|
class SessionSigner:
|
|
24
|
-
def __init__(self, secret: str | bytes | None = None) -> None:
|
|
43
|
+
def __init__(self, secret: str | bytes | None = None, cookie_name: str = SESSION_COOKIE) -> None:
|
|
25
44
|
secret = secret if secret is not None else os.environ.get(SESSION_SECRET_ENV)
|
|
26
45
|
self._key = (secret.encode() if isinstance(secret, str) else secret) or secrets.token_bytes(SECRET_BYTES)
|
|
46
|
+
self.cookie_name = cookie_name
|
|
27
47
|
|
|
28
48
|
def encode(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> str:
|
|
29
49
|
"""The cookie value for ``user`` signed in, or signed out when None, good for ``ttl_s`` seconds."""
|
|
@@ -45,10 +65,14 @@ class SessionSigner:
|
|
|
45
65
|
return None
|
|
46
66
|
return {"user": data.get("u")}
|
|
47
67
|
|
|
68
|
+
def read(self, cookies: dict) -> dict | None:
|
|
69
|
+
"""The session of a request's ``cookies``, from the site's own cookie."""
|
|
70
|
+
return self.decode(cookies.get(self.cookie_name))
|
|
71
|
+
|
|
48
72
|
def cookie(self, user: str | None, ttl_s: int = SESSION_TTL_S) -> dict:
|
|
49
73
|
"""The cookie a browser adds to be signed in as ``user``, in browser/v1's shape with ``max_age`` for its
|
|
50
74
|
lifetime; its domain is left to whoever adds it."""
|
|
51
|
-
return {"name":
|
|
75
|
+
return {"name": self.cookie_name, "value": self.encode(user, ttl_s), "path": "/", "max_age": ttl_s,
|
|
52
76
|
"httpOnly": True, "sameSite": "Lax"}
|
|
53
77
|
|
|
54
78
|
def _sign(self, payload: str) -> str:
|