agentenv-framework-protocol 0.1.458__tar.gz → 0.1.459__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.458 → agentenv_framework_protocol-0.1.459}/PKG-INFO +32 -1
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/README.md +31 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/pyproject.toml +1 -1
- agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/__init__.py +41 -0
- agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/clock.py +142 -0
- agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/conformance.py +358 -0
- agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/environment.py +431 -0
- agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/errors.py +39 -0
- agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/readiness.py +81 -0
- agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/routes.py +397 -0
- agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/session.py +55 -0
- agentenv_framework_protocol-0.1.459/tests/test_website_api.py +453 -0
- agentenv_framework_protocol-0.1.459/tests/test_website_conformance.py +331 -0
- agentenv_framework_protocol-0.1.459/tests/test_website_environment.py +720 -0
- agentenv_framework_protocol-0.1.459/tests/test_website_readiness.py +212 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/browser_cdp.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/_declare.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/_linux.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/_load.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/_with_fds.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/app.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/app_data.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/app_runtime.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/apps.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/card.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/checks.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/client.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/report.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/driver.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/errors.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/frames.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/keys.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/launch.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/operations.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/platform.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/queue.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/recording.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/results.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/run_install_step.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/runtime.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/seed.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/serve.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/serve_app.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/session.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/settings.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/skill.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/spaces.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/svc.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/tools.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/errors.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/website_browser.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/computer_fake.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/probe_fake.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_cdp_config.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_proxy.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_browser_cdp.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_app_data.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_app_server.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_conformance.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_conformance_checks.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_conformance_page.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_conformance_recording.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_dataplane.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_declare.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_driver.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_extensions.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_frames.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_page.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_queue.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_recording.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_seed.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_serve.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_spaces.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_svc_forward.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_control_token.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_data_objects.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_errors.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_extension_methods.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_preflight.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_access.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_annotations.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_context_fastmcp.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_context_injection.py +0 -0
- {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_website_browser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentenv-framework-protocol
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.459
|
|
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
|
|
@@ -490,6 +490,37 @@ async with ToolSession(mcp_url=mcp_url, role="alice@example.com") as session:
|
|
|
490
490
|
- **Tools:** `list_tools()` returns the tools the role may use as `EnvironmentTool`, the type a card's
|
|
491
491
|
`capabilities.tools` holds. Each `inputSchema` is the JSON Schema a model API takes as the tool's parameters.
|
|
492
492
|
|
|
493
|
+
## Website readiness
|
|
494
|
+
|
|
495
|
+
`agentenv_protocol.website` is the one definition of whether a website env is healthy. A site's card advertises
|
|
496
|
+
`urn:agentenv:website/v1`'s `ready` (`WEBSITE_READY_OP`), a GET answering a `WebsiteReadiness`: `ready`, a boolean,
|
|
497
|
+
`problems`, what keeps it from being ready, and optionally `waited_s` since its server started and the `timeout_s` it
|
|
498
|
+
may take (`SITE_READY_TIMEOUT_S` when it says nothing), which is how long a deploy waits for it. A
|
|
499
|
+
`WebsiteEnvironment` answers from its `@ready_check` methods, run at once, each within `CHECK_TIMEOUT_S` and a plain
|
|
500
|
+
`def` in a worker thread, and from `@website(ready_timeout_s=...)`; it serves no separate health path. The model is
|
|
501
|
+
strict about `ready` and `problems` (`"yes"` is no `ready`) and ignores fields it doesn't know, so a reader keeps
|
|
502
|
+
reading a newer site. A server whose card has no `ready` is still read at its health path (`DEFAULT_HEALTH_PATH`
|
|
503
|
+
unless it names one), which is deprecated. Everything that judges a site reads it through these helpers: agent-env's deploy,
|
|
504
|
+
`DeployedEnv.readiness()`, and the conformance checker's `health`, which fails an answer that isn't a
|
|
505
|
+
`WebsiteReadiness`.
|
|
506
|
+
|
|
507
|
+
```python
|
|
508
|
+
from agentenv_protocol.website import env_readiness, website_readiness
|
|
509
|
+
|
|
510
|
+
site = await website_readiness(base_url, card) # WebsiteReadiness, or None for a card without ready
|
|
511
|
+
env = await env_readiness(gateway_url, env_card) # WebsiteReadiness of every site of the env
|
|
512
|
+
print(env.ready, env.problems) # False ['shop: seeded']
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
- **One site:** `website_readiness(base_url, card, *, timeout=READY_TIMEOUT_S, headers=None)` asks the site as its card
|
|
516
|
+
advertises `ready`. It raises httpx's error when the site can't be reached, `ExtensionCallError` for a non-2xx
|
|
517
|
+
answer and `ValueError` (pydantic's `ValidationError`) for an answer that isn't a `WebsiteReadiness`.
|
|
518
|
+
- **An env:** `env_readiness(base_url, card, ...)` asks, at once, the card itself when it advertises `ready` (a single
|
|
519
|
+
site's card), and every child under a composed card's `children_environments` that does, its endpoints rooted at
|
|
520
|
+
`base_url` as a gateway's are. A child's problems are prefixed with its name (`shop: seeded`); one that can't be
|
|
521
|
+
asked, or answers no `WebsiteReadiness`, is a problem itself. A card or child without `ready`, an MCP server's or an
|
|
522
|
+
older site's, isn't counted, so an env with no site serving it is ready with no problems.
|
|
523
|
+
|
|
493
524
|
## Computer drivers
|
|
494
525
|
|
|
495
526
|
A computer driver is what runs inside a computer an agent uses through screenshots and input. Write
|
|
@@ -431,6 +431,37 @@ async with ToolSession(mcp_url=mcp_url, role="alice@example.com") as session:
|
|
|
431
431
|
- **Tools:** `list_tools()` returns the tools the role may use as `EnvironmentTool`, the type a card's
|
|
432
432
|
`capabilities.tools` holds. Each `inputSchema` is the JSON Schema a model API takes as the tool's parameters.
|
|
433
433
|
|
|
434
|
+
## Website readiness
|
|
435
|
+
|
|
436
|
+
`agentenv_protocol.website` is the one definition of whether a website env is healthy. A site's card advertises
|
|
437
|
+
`urn:agentenv:website/v1`'s `ready` (`WEBSITE_READY_OP`), a GET answering a `WebsiteReadiness`: `ready`, a boolean,
|
|
438
|
+
`problems`, what keeps it from being ready, and optionally `waited_s` since its server started and the `timeout_s` it
|
|
439
|
+
may take (`SITE_READY_TIMEOUT_S` when it says nothing), which is how long a deploy waits for it. A
|
|
440
|
+
`WebsiteEnvironment` answers from its `@ready_check` methods, run at once, each within `CHECK_TIMEOUT_S` and a plain
|
|
441
|
+
`def` in a worker thread, and from `@website(ready_timeout_s=...)`; it serves no separate health path. The model is
|
|
442
|
+
strict about `ready` and `problems` (`"yes"` is no `ready`) and ignores fields it doesn't know, so a reader keeps
|
|
443
|
+
reading a newer site. A server whose card has no `ready` is still read at its health path (`DEFAULT_HEALTH_PATH`
|
|
444
|
+
unless it names one), which is deprecated. Everything that judges a site reads it through these helpers: agent-env's deploy,
|
|
445
|
+
`DeployedEnv.readiness()`, and the conformance checker's `health`, which fails an answer that isn't a
|
|
446
|
+
`WebsiteReadiness`.
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
from agentenv_protocol.website import env_readiness, website_readiness
|
|
450
|
+
|
|
451
|
+
site = await website_readiness(base_url, card) # WebsiteReadiness, or None for a card without ready
|
|
452
|
+
env = await env_readiness(gateway_url, env_card) # WebsiteReadiness of every site of the env
|
|
453
|
+
print(env.ready, env.problems) # False ['shop: seeded']
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
- **One site:** `website_readiness(base_url, card, *, timeout=READY_TIMEOUT_S, headers=None)` asks the site as its card
|
|
457
|
+
advertises `ready`. It raises httpx's error when the site can't be reached, `ExtensionCallError` for a non-2xx
|
|
458
|
+
answer and `ValueError` (pydantic's `ValidationError`) for an answer that isn't a `WebsiteReadiness`.
|
|
459
|
+
- **An env:** `env_readiness(base_url, card, ...)` asks, at once, the card itself when it advertises `ready` (a single
|
|
460
|
+
site's card), and every child under a composed card's `children_environments` that does, its endpoints rooted at
|
|
461
|
+
`base_url` as a gateway's are. A child's problems are prefixed with its name (`shop: seeded`); one that can't be
|
|
462
|
+
asked, or answers no `WebsiteReadiness`, is a problem itself. A card or child without `ready`, an MCP server's or an
|
|
463
|
+
older site's, isn't counted, so an env with no site serving it is ready with no problems.
|
|
464
|
+
|
|
434
465
|
## Computer drivers
|
|
435
466
|
|
|
436
467
|
A computer driver is what runs inside a computer an agent uses through screenshots and input. Write
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The website SDK: a site's server, meeting ``urn:agentenv:website/v1``. Subclass WebsiteEnvironment, declare the
|
|
2
|
+
site with ``@website(domains=[...], static="dist/")``, mark its API with ``@api.get/post/put/patch/delete(path)``, and
|
|
3
|
+
``serve()`` it; it needs only the plain package, and uvicorn to serve. The class is optional: a server in any language
|
|
4
|
+
that meets the contract works, which ``python -m agentenv_protocol.website.conformance <url>`` checks. Whether a site
|
|
5
|
+
is ready is its ``WebsiteReadiness`` answer to ``ready``, which ``website_readiness`` reads of one site and
|
|
6
|
+
``env_readiness`` of every site of an env. What a task left a site with is its data plane's ``data/get``."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, ClockUnavailable, UniverseClock
|
|
11
|
+
from .environment import (
|
|
12
|
+
SESSION_PATH,
|
|
13
|
+
WebsiteDeclaration,
|
|
14
|
+
WebsiteEnvironment,
|
|
15
|
+
host_problem,
|
|
16
|
+
ready_check,
|
|
17
|
+
website,
|
|
18
|
+
)
|
|
19
|
+
from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized
|
|
20
|
+
from .readiness import (
|
|
21
|
+
DEFAULT_HEALTH_PATH,
|
|
22
|
+
READY_TIMEOUT_S,
|
|
23
|
+
SITE_READY_TIMEOUT_S,
|
|
24
|
+
WEBSITE_EXTENSION_URI,
|
|
25
|
+
WEBSITE_READY_OP,
|
|
26
|
+
WebsiteReadiness,
|
|
27
|
+
env_readiness,
|
|
28
|
+
website_readiness,
|
|
29
|
+
)
|
|
30
|
+
from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path
|
|
31
|
+
from .session import SESSION_COOKIE, SESSION_SECRET_ENV, SessionSigner
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check", "host_problem",
|
|
35
|
+
"ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict",
|
|
36
|
+
"UniverseClock", "ClockUnavailable", "SessionSigner", "reserved_path",
|
|
37
|
+
"WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH",
|
|
38
|
+
"SESSION_COOKIE", "SESSION_SECRET_ENV", "ROLE_KEY_ENV", "ROLE_KEY_HEADER",
|
|
39
|
+
"WEBSITE_READY_OP", "READY_TIMEOUT_S", "SITE_READY_TIMEOUT_S", "WebsiteReadiness",
|
|
40
|
+
"website_readiness", "env_readiness",
|
|
41
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The universe's clock as a site reads it, the way MCP servers read it: ``clock/v1``'s ``sync_time`` hands the server
|
|
2
|
+
the gateway's ``/clock/time`` URL, and each request then reads that clock's ``{"virtual_time": <RFC3339>}`` once,
|
|
3
|
+
the one instant everything the request does reads (``now()``) and its answer carries as ``X-Universe-Now``, RFC3339 in
|
|
4
|
+
UTC. Requests that arrive while a read is in flight share it, over one pooled connection, so a burst costs the gateway
|
|
5
|
+
one read. An unsynced site reads wall time, and so does a synced one whose gateway clock isn't armed (404); one whose clock
|
|
6
|
+
can't be read raises ``ClockUnavailable``. ``sync_time`` with an empty URL goes back to wall time. ``sync_time`` may
|
|
7
|
+
also name the universe's time zone, an IANA name, which the clock keeps for the site to show local times in; the time
|
|
8
|
+
itself stays UTC."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextvars
|
|
13
|
+
import datetime
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
CLOCK_EXTENSION_URI = "urn:agentenv:clock/v1"
|
|
20
|
+
UNIVERSE_NOW_HEADER = "X-Universe-Now"
|
|
21
|
+
READ_TIMEOUT = httpx.Timeout(connect=0.5, read=1.0, write=1.0, pool=0.5)
|
|
22
|
+
READ_ATTEMPTS = 2
|
|
23
|
+
|
|
24
|
+
_FRACTION = re.compile(r"(\.\d+)(?=[+-]\d\d:?\d\d$|Z$|$)")
|
|
25
|
+
_request_now: contextvars.ContextVar[datetime.datetime | None] = contextvars.ContextVar("universe_now", default=None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ClockUnavailable(Exception):
|
|
29
|
+
"""The synced clock couldn't be read: unreachable, a 5xx after a retry, or an answer that isn't a time."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def iso(moment: datetime.datetime) -> str:
|
|
33
|
+
return moment.astimezone(datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_time(value: str) -> datetime.datetime:
|
|
37
|
+
"""An RFC3339 time, its fraction of a second of any length (Python 3.10 parses only 3 or 6 digits)."""
|
|
38
|
+
value = _FRACTION.sub(lambda m: m.group(1)[:7].ljust(7, "0"), value.replace("Z", "+00:00"))
|
|
39
|
+
moment = datetime.datetime.fromisoformat(value)
|
|
40
|
+
return moment if moment.tzinfo else moment.replace(tzinfo=datetime.timezone.utc)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _wall() -> datetime.datetime:
|
|
44
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UniverseClock:
|
|
48
|
+
"""The time a site answers with: wall time until ``sync`` names a gateway clock, that clock's, read per request,
|
|
49
|
+
after."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.url: str | None = None
|
|
53
|
+
self.timezone: str | None = None
|
|
54
|
+
self._last: datetime.datetime | None = None
|
|
55
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
56
|
+
self._client: httpx.AsyncClient | None = None
|
|
57
|
+
self._reading: tuple[str, asyncio.Future] | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def synced(self) -> bool:
|
|
61
|
+
return self.url is not None
|
|
62
|
+
|
|
63
|
+
def now(self) -> datetime.datetime:
|
|
64
|
+
"""The instant the request being answered read, else the last one read, else wall time."""
|
|
65
|
+
return _request_now.get() or self._last or _wall()
|
|
66
|
+
|
|
67
|
+
async def read(self) -> datetime.datetime:
|
|
68
|
+
"""The universe's time now: the synced clock's, wall time when none is synced or it isn't armed; raises
|
|
69
|
+
``ClockUnavailable`` when it can't be read."""
|
|
70
|
+
if self.url is None:
|
|
71
|
+
return _wall()
|
|
72
|
+
self._last = await self._shared_read(self.url) or _wall()
|
|
73
|
+
return self._last
|
|
74
|
+
|
|
75
|
+
def answering_at(self, moment: datetime.datetime) -> contextvars.Token:
|
|
76
|
+
"""Make ``moment`` what ``now()`` reads while the current request is answered."""
|
|
77
|
+
return _request_now.set(moment)
|
|
78
|
+
|
|
79
|
+
def answered(self, token: contextvars.Token) -> None:
|
|
80
|
+
_request_now.reset(token)
|
|
81
|
+
|
|
82
|
+
async def sync(self, url: str, timezone: str | None = None) -> datetime.datetime:
|
|
83
|
+
"""Follow the clock at ``url`` from now on, in ``timezone`` (none when the sync names none), or wall time again
|
|
84
|
+
for an empty ``url``; raises ``ClockUnavailable`` when the clock can't be read, keeping the clock and zone it
|
|
85
|
+
followed."""
|
|
86
|
+
if not url:
|
|
87
|
+
self.url, self.timezone, self._last, self._reading = None, None, None, None
|
|
88
|
+
return _wall()
|
|
89
|
+
virtual = await _read(self._pooled(), url) or _wall()
|
|
90
|
+
self.url, self.timezone, self._last, self._reading = url, timezone, virtual, None
|
|
91
|
+
return virtual
|
|
92
|
+
|
|
93
|
+
async def _shared_read(self, url: str) -> datetime.datetime | None:
|
|
94
|
+
"""The clock at ``url``'s time, from the read already in flight for it when there is one, else a new one."""
|
|
95
|
+
client = self._pooled()
|
|
96
|
+
if self._reading is None or self._reading[0] != url or self._reading[1].done():
|
|
97
|
+
reading = asyncio.ensure_future(_read(client, url))
|
|
98
|
+
reading.add_done_callback(lambda done: done.cancelled() or done.exception())
|
|
99
|
+
self._reading = (url, reading)
|
|
100
|
+
return await asyncio.shield(self._reading[1])
|
|
101
|
+
|
|
102
|
+
def _pooled(self) -> httpx.AsyncClient:
|
|
103
|
+
"""The client this event loop reads the clock with, kept so its connections are reused."""
|
|
104
|
+
loop = asyncio.get_running_loop()
|
|
105
|
+
if self._loop is not loop or self._client is None:
|
|
106
|
+
self._loop, self._client, self._reading = loop, httpx.AsyncClient(timeout=READ_TIMEOUT), None
|
|
107
|
+
return self._client
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _read(client: httpx.AsyncClient, url: str) -> datetime.datetime | None:
|
|
111
|
+
"""The clock at ``url``'s time, None when it isn't armed (404); a transport error or a 5xx is tried again once."""
|
|
112
|
+
failure: Exception | None = None
|
|
113
|
+
for _attempt in range(READ_ATTEMPTS):
|
|
114
|
+
try:
|
|
115
|
+
response = await client.get(url)
|
|
116
|
+
except httpx.TransportError as e:
|
|
117
|
+
failure = e
|
|
118
|
+
continue
|
|
119
|
+
if response.status_code == 404:
|
|
120
|
+
return None
|
|
121
|
+
if response.status_code >= 500:
|
|
122
|
+
failure = httpx.HTTPStatusError(f"{response.status_code}", request=response.request, response=response)
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
response.raise_for_status()
|
|
126
|
+
return parse_time(response.json()["virtual_time"])
|
|
127
|
+
except (httpx.HTTPStatusError, ValueError, KeyError, TypeError) as e:
|
|
128
|
+
raise ClockUnavailable(f"the universe's clock at {url} answered {response.status_code}: {e}") from e
|
|
129
|
+
raise ClockUnavailable(f"the universe's clock at {url} can't be read: {type(failure).__name__}: {failure}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def clock_patch_script(now: datetime.datetime) -> bytes:
|
|
133
|
+
"""An inline script that shifts the page's ``Date`` to the universe's time ``now``, and follows the
|
|
134
|
+
``X-Universe-Now`` header of every ``fetch`` the page makes after. The ``Date`` it puts in place answers called,
|
|
135
|
+
with the time's string, as constructed, and keeps ``Date.parse``, ``Date.UTC`` and ``instanceof Date``."""
|
|
136
|
+
return ("<script>(function(){var D=Date,o=%s-D.now();function n(){return D.now()+o}"
|
|
137
|
+
"function U(){if(!new.target)return new D(n()).toString();"
|
|
138
|
+
"return Reflect.construct(D,arguments.length?[].slice.call(arguments):[n()],new.target)}"
|
|
139
|
+
"Object.setPrototypeOf(U,D);U.prototype=D.prototype;D.prototype.constructor=U;U.now=n;U.parse=D.parse;"
|
|
140
|
+
"U.UTC=D.UTC;window.Date=U;var f=window.fetch;if(f)window.fetch=function(){return f.apply(this,arguments)"
|
|
141
|
+
".then(function(r){var t=D.parse(r.headers.get(%s)||'');if(!isNaN(t))o=t-D.now();return r})}})();"
|
|
142
|
+
"</script>" % (int(now.timestamp() * 1000), json.dumps(UNIVERSE_NOW_HEADER))).encode()
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Check a running site's server against ``urn:agentenv:website/v1``, whatever it's written in:
|
|
2
|
+
``python -m agentenv_protocol.website.conformance <url>`` prints each check and exits 1 when one failed (a warning
|
|
3
|
+
doesn't), 2 when the server couldn't be reached. Everything goes over HTTP: the card, ``/`` under a made-up host, readiness by the card's
|
|
4
|
+
``website/v1`` ``ready``, whose answer must be a ``WebsiteReadiness`` (by the declared health path, deprecated, for a
|
|
5
|
+
card without it), ``X-Universe-Now`` on every answer the other checks got, a data-plane round trip at the card's ``url``
|
|
6
|
+
(data/get, data/reset, data/get, which no longer returns the exported data, data/add of what the first data/get
|
|
7
|
+
returned, data/get again), ``clock/v1``'s
|
|
8
|
+
``sync_time`` against a clock the checker serves, and the forwarded headers wherever an answer shows them, on ``/`` and
|
|
9
|
+
on a GET route with its trailing slash toggled, which a server may redirect, and the cookies those answers set:
|
|
10
|
+
none tied to a domain, and a warning for one whose lifetime is an absolute ``Expires`` with no ``Max-Age``.
|
|
11
|
+
|
|
12
|
+
Every request goes to the server under check: a card's path or URL naming another scheme, host or port fails the check
|
|
13
|
+
that would have used it, with nothing sent there. The round trip leaves the server's data as it found it, adding the
|
|
14
|
+
export back when a step after the reset fails too. The clock check ends by syncing the server back to wall time (an
|
|
15
|
+
empty ``env_get_time_url``), since the checker's clock is gone once the check ends. That clock starts at
|
|
16
|
+
``CLOCK_START``, far enough from wall time that a synced server shows it, and a synced server's ``X-Universe-Now``
|
|
17
|
+
may be ``CLOCK_SLACK_S`` seconds from it."""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import contextlib
|
|
22
|
+
import dataclasses
|
|
23
|
+
import datetime
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
30
|
+
from typing import Callable, Iterator, Literal
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
from pydantic import ValidationError
|
|
34
|
+
|
|
35
|
+
from ..client import find_extension, find_extension_method
|
|
36
|
+
from ..types import METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH
|
|
37
|
+
from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, iso, parse_time
|
|
38
|
+
from .readiness import DEFAULT_HEALTH_PATH, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP, WebsiteReadiness
|
|
39
|
+
|
|
40
|
+
Status = Literal["passed", "failed", "skipped", "warned"]
|
|
41
|
+
CLOCK_START = datetime.datetime(2031, 5, 6, 7, 8, 9, tzinfo=datetime.timezone.utc)
|
|
42
|
+
CLOCK_SLACK_S = 120
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclasses.dataclass(frozen=True)
|
|
46
|
+
class Result:
|
|
47
|
+
name: str
|
|
48
|
+
status: Status
|
|
49
|
+
detail: str = ""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def passed(self) -> bool:
|
|
53
|
+
return self.status != "failed"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check(base_url: str, *, client: httpx.Client | None = None, clock_host: str = "127.0.0.1",
|
|
57
|
+
timeout: float = 30) -> list[Result]:
|
|
58
|
+
"""Each check's result against the server at ``base_url``. ``clock_host`` is the address the server reaches the
|
|
59
|
+
checker's clock at. Raises httpx's error when the server can't be reached at all."""
|
|
60
|
+
own = client is None
|
|
61
|
+
client = client or httpx.Client(timeout=timeout)
|
|
62
|
+
try:
|
|
63
|
+
return _Checker(base_url.rstrip("/"), client, clock_host).run()
|
|
64
|
+
finally:
|
|
65
|
+
if own:
|
|
66
|
+
client.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Checker:
|
|
70
|
+
def __init__(self, base_url: str, client: httpx.Client, clock_host: str) -> None:
|
|
71
|
+
self.base_url, self.client, self.clock_host = base_url, client, clock_host
|
|
72
|
+
self.rpc_url = base_url + RPC_PATH
|
|
73
|
+
self.responses: list[httpx.Response] = []
|
|
74
|
+
|
|
75
|
+
def get(self, path: str, **kwargs) -> httpx.Response:
|
|
76
|
+
return self._seen(self.client.get(self._url(path), **kwargs))
|
|
77
|
+
|
|
78
|
+
def _url(self, where: str) -> str:
|
|
79
|
+
"""Where ``where``, a path or a full URL a card names, is; raises ``OffServer`` for one on another server."""
|
|
80
|
+
url = where if "://" in where else self.base_url + (where if where.startswith("/") else "/" + where)
|
|
81
|
+
if _origin(url) != _origin(self.base_url):
|
|
82
|
+
raise OffServer(f"the card points outside the server under check, at {url}")
|
|
83
|
+
return url
|
|
84
|
+
|
|
85
|
+
def _seen(self, response: httpx.Response) -> httpx.Response:
|
|
86
|
+
self.responses.append(response)
|
|
87
|
+
return response
|
|
88
|
+
|
|
89
|
+
def run(self) -> list[Result]:
|
|
90
|
+
response = self.get(WELL_KNOWN_PATH)
|
|
91
|
+
try:
|
|
92
|
+
card = response.json() if response.status_code == 200 else {}
|
|
93
|
+
except ValueError:
|
|
94
|
+
card = {}
|
|
95
|
+
site = (find_extension(card, WEBSITE_EXTENSION_URI) or {}).get("params") or {}
|
|
96
|
+
health = site.get("health") if isinstance(site.get("health"), str) else DEFAULT_HEALTH_PATH
|
|
97
|
+
steps = (("any_host", self._any_host), ("health", lambda: self._health(card, health)),
|
|
98
|
+
("universe_now", self._universe_now), ("data_plane", lambda: self._data_plane(card)),
|
|
99
|
+
("clock", lambda: self._clock(card)), ("forwarded", lambda: self._forwarded(site)),
|
|
100
|
+
("cookies", lambda: self._cookies(site)))
|
|
101
|
+
outcomes = {name: _outcome(step) for name, step in steps if name != "universe_now"}
|
|
102
|
+
outcomes["universe_now"] = _outcome(self._universe_now)
|
|
103
|
+
return [self._card(response, card, site), *(Result(name, *outcomes[name]) for name, _step in steps)]
|
|
104
|
+
|
|
105
|
+
def _card(self, response: httpx.Response, card: dict, site: dict) -> Result:
|
|
106
|
+
if response.status_code != 200:
|
|
107
|
+
return Result("card", "failed", f"{WELL_KNOWN_PATH} answered {response.status_code}")
|
|
108
|
+
problems = []
|
|
109
|
+
if not site:
|
|
110
|
+
problems.append(f"declares no {WEBSITE_EXTENSION_URI} with params")
|
|
111
|
+
if not isinstance(site.get("domains"), list):
|
|
112
|
+
problems.append("its params have no domains list")
|
|
113
|
+
if find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP) is None and not (
|
|
114
|
+
isinstance(site.get("health"), str) and site["health"].startswith("/")):
|
|
115
|
+
problems.append(f"it has no {WEBSITE_READY_OP}, and its params no health path")
|
|
116
|
+
routes = site.get("routes")
|
|
117
|
+
if not isinstance(routes, list) or not all(isinstance(r, dict) and {"method", "path", "name"} <= set(r)
|
|
118
|
+
for r in routes):
|
|
119
|
+
problems.append("its params' routes aren't a list of {method, path, name}")
|
|
120
|
+
return Result("card", "failed" if problems else "passed", "; ".join(problems))
|
|
121
|
+
|
|
122
|
+
def _any_host(self) -> tuple[Status, str]:
|
|
123
|
+
host = f"conformance-{uuid.uuid4().hex[:8]}.invalid"
|
|
124
|
+
response = self.get("/", headers={"Host": host})
|
|
125
|
+
kind = response.headers.get("content-type", "")
|
|
126
|
+
if response.status_code == 200 and kind.startswith("text/html"):
|
|
127
|
+
return "passed", f"/ served HTML for Host {host}"
|
|
128
|
+
return "failed", f"/ for Host {host} answered {response.status_code} {kind or 'without a content type'}"
|
|
129
|
+
|
|
130
|
+
def _health(self, card: dict, health: str) -> tuple[Status, str]:
|
|
131
|
+
ready = find_extension_method(card, WEBSITE_EXTENSION_URI, WEBSITE_READY_OP)
|
|
132
|
+
if ready is None:
|
|
133
|
+
response = self.get(health)
|
|
134
|
+
return (("passed" if response.status_code == 200 else "failed"),
|
|
135
|
+
f"{health} answered {response.status_code}; the card has no website/v1 ready, so the health path "
|
|
136
|
+
"was read, which is deprecated")
|
|
137
|
+
response = self._seen(self.client.request(ready.get("method", "GET"), self._url(ready["endpoint"])))
|
|
138
|
+
try:
|
|
139
|
+
answer = response.json()
|
|
140
|
+
except ValueError:
|
|
141
|
+
return "failed", f"website/v1 ready answered {response.status_code} with no JSON: {response.text[:200]!r}"
|
|
142
|
+
try:
|
|
143
|
+
readiness = WebsiteReadiness.model_validate(answer)
|
|
144
|
+
except ValidationError as e:
|
|
145
|
+
return "failed", (f"website/v1 ready answered {answer!r}, not a WebsiteReadiness: "
|
|
146
|
+
f"{'; '.join(_field_error(error) for error in e.errors())}")
|
|
147
|
+
if not response.is_success:
|
|
148
|
+
return "failed", (f"website/v1 ready answered {response.status_code}: "
|
|
149
|
+
f"{', '.join(readiness.problems) or 'no problem named'}")
|
|
150
|
+
if readiness.ready:
|
|
151
|
+
return "passed", "website/v1 ready says it is ready"
|
|
152
|
+
return "failed", f"website/v1 ready says it is not ready: {', '.join(readiness.problems) or 'no problem named'}"
|
|
153
|
+
|
|
154
|
+
def _universe_now(self) -> tuple[Status, str]:
|
|
155
|
+
missing = [str(r.url.path) for r in self.responses if UNIVERSE_NOW_HEADER not in r.headers]
|
|
156
|
+
if missing:
|
|
157
|
+
return "failed", f"no {UNIVERSE_NOW_HEADER} on {', '.join(missing)}"
|
|
158
|
+
for response in self.responses:
|
|
159
|
+
parse_time(response.headers[UNIVERSE_NOW_HEADER])
|
|
160
|
+
return "passed", f"on all {len(self.responses)} answers"
|
|
161
|
+
|
|
162
|
+
def _rpc(self, method: str, params: dict | None = None) -> dict:
|
|
163
|
+
response = self._seen(self.client.post(self.rpc_url, json={"jsonrpc": "2.0", "id": method,
|
|
164
|
+
"method": method, "params": params or {}}))
|
|
165
|
+
body = response.json()
|
|
166
|
+
if isinstance(body, dict) and "error" in body:
|
|
167
|
+
raise RuntimeError(f"{method} answered {body['error']}")
|
|
168
|
+
if not response.is_success or not isinstance(body, dict) or "result" not in body:
|
|
169
|
+
raise RuntimeError(f"{method} answered {response.status_code} with no JSON-RPC result: {response.text[:200]}")
|
|
170
|
+
return body["result"] or {}
|
|
171
|
+
|
|
172
|
+
def _data_plane(self, card: dict) -> tuple[Status, str]:
|
|
173
|
+
operations = (card.get("capabilities") or {}).get("operations")
|
|
174
|
+
missing = [m for m in (METHOD_GET, METHOD_RESET, METHOD_ADD) if operations is not None and m not in operations]
|
|
175
|
+
if missing:
|
|
176
|
+
return "failed", f"the card's operations lack {', '.join(missing)}"
|
|
177
|
+
url = card.get("url")
|
|
178
|
+
self.rpc_url = self._url(url if isinstance(url, str) and url else RPC_PATH)
|
|
179
|
+
exported = self._rpc(METHOD_GET).get("parts") or []
|
|
180
|
+
self._rpc(METHOD_RESET)
|
|
181
|
+
added_back = False
|
|
182
|
+
try:
|
|
183
|
+
cleared = self._rpc(METHOD_GET).get("parts") or []
|
|
184
|
+
if _holds_data(exported) and cleared == exported:
|
|
185
|
+
added_back = True
|
|
186
|
+
return "failed", "data/get after data/reset returned the data it returned before"
|
|
187
|
+
if not exported:
|
|
188
|
+
added_back = True
|
|
189
|
+
return "passed", "data/get, data/reset and data/get answered; data/get returned nothing to add back"
|
|
190
|
+
added_back = True
|
|
191
|
+
self._rpc(METHOD_ADD, {"parts": exported})
|
|
192
|
+
again = self._rpc(METHOD_GET).get("parts") or []
|
|
193
|
+
finally:
|
|
194
|
+
if not added_back:
|
|
195
|
+
with contextlib.suppress(Exception):
|
|
196
|
+
self._rpc(METHOD_ADD, {"parts": exported})
|
|
197
|
+
if again != exported:
|
|
198
|
+
return "failed", "data/get after data/reset and data/add of its own export returned other data"
|
|
199
|
+
return "passed", f"{len(exported)} part(s) round-tripped through reset and add"
|
|
200
|
+
|
|
201
|
+
def _clock(self, card: dict) -> tuple[Status, str]:
|
|
202
|
+
if find_extension(card, CLOCK_EXTENSION_URI) is None:
|
|
203
|
+
return "failed", f"the card declares no {CLOCK_EXTENSION_URI}"
|
|
204
|
+
method = find_extension_method(card, CLOCK_EXTENSION_URI, "sync_time")
|
|
205
|
+
if method is None or not method.get("endpoint"):
|
|
206
|
+
return "failed", f"{CLOCK_EXTENSION_URI} advertises no sync_time endpoint"
|
|
207
|
+
endpoint = self._url(method["endpoint"])
|
|
208
|
+
with _clock_server(self.clock_host) as (url, virtual_now):
|
|
209
|
+
response = self._seen(self.client.request(method["method"], endpoint, json={"env_get_time_url": url}))
|
|
210
|
+
if response.status_code >= 400:
|
|
211
|
+
return "failed", f"sync_time answered {response.status_code}: {response.text[:200]}"
|
|
212
|
+
try:
|
|
213
|
+
seen = self.get("/").headers.get(UNIVERSE_NOW_HEADER)
|
|
214
|
+
expected = virtual_now()
|
|
215
|
+
finally:
|
|
216
|
+
with contextlib.suppress(Exception):
|
|
217
|
+
self.client.request(method["method"], endpoint, json={"env_get_time_url": ""})
|
|
218
|
+
if seen is None:
|
|
219
|
+
return "failed", f"no {UNIVERSE_NOW_HEADER} after sync_time"
|
|
220
|
+
off = abs((parse_time(seen) - expected).total_seconds())
|
|
221
|
+
if off > CLOCK_SLACK_S:
|
|
222
|
+
return "failed", f"after sync_time {UNIVERSE_NOW_HEADER} is {seen}, {off:.0f}s from the clock's {iso(expected)}"
|
|
223
|
+
return "passed", f"{UNIVERSE_NOW_HEADER} followed the clock to {seen}"
|
|
224
|
+
|
|
225
|
+
def _forwarded(self, site: dict) -> tuple[Status, str]:
|
|
226
|
+
internal = f"internal-{uuid.uuid4().hex[:8]}.invalid"
|
|
227
|
+
forwarded = f"site-{uuid.uuid4().hex[:8]}.example"
|
|
228
|
+
headers = {"Host": internal, "X-Forwarded-Host": forwarded, "X-Forwarded-Proto": "https"}
|
|
229
|
+
paths = ["/", *_slash_toggled(site.get("routes"))]
|
|
230
|
+
for path in paths:
|
|
231
|
+
response = self.get(path, headers=headers, follow_redirects=False)
|
|
232
|
+
location = response.headers.get("location", "")
|
|
233
|
+
shown = [where for where, text in (("body", response.text), ("Location", location)) if internal in text]
|
|
234
|
+
if shown:
|
|
235
|
+
return "failed", f"the {' and '.join(shown)} of {path} named the internal Host {internal}, not {forwarded}"
|
|
236
|
+
if location.startswith(f"http://{forwarded}"):
|
|
237
|
+
return "failed", f"the Location of {path} is {location}, not https as X-Forwarded-Proto said"
|
|
238
|
+
return "passed", f"no answer to {', '.join(paths)} named the internal Host"
|
|
239
|
+
|
|
240
|
+
def _cookies(self, site: dict) -> tuple[Status, str]:
|
|
241
|
+
if isinstance(site.get("session_path"), str):
|
|
242
|
+
self.get(site["session_path"])
|
|
243
|
+
cookies = [c for r in self.responses for c in r.headers.get_list("set-cookie")]
|
|
244
|
+
if not cookies:
|
|
245
|
+
return "skipped", "no answer set a cookie"
|
|
246
|
+
scoped = sorted({c.split("=", 1)[0] for c in cookies if "domain" in _attributes(c)})
|
|
247
|
+
if scoped:
|
|
248
|
+
return "failed", f"cookies tied to a domain: {', '.join(scoped)}"
|
|
249
|
+
absolute = sorted({c.split("=", 1)[0] for c in cookies if "expires" in _attributes(c) and "max-age" not in _attributes(c)})
|
|
250
|
+
if absolute:
|
|
251
|
+
return "warned", (f"cookies with an absolute Expires and no Max-Age, which a browser on another clock "
|
|
252
|
+
f"misjudges: {', '.join(absolute)}")
|
|
253
|
+
return "passed", f"{len(cookies)} cookie(s), none tied to a domain"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _attributes(set_cookie: str) -> set[str]:
|
|
257
|
+
"""The attribute names of a Set-Cookie header, lowercased."""
|
|
258
|
+
return {a.split("=", 1)[0].strip().lower() for a in set_cookie.split(";")[1:]}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class OffServer(ValueError):
|
|
262
|
+
"""A card named a path or URL on a server other than the one under check."""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
|
266
|
+
"""A URL's scheme, host and port, the port its scheme's default when it names none."""
|
|
267
|
+
parsed = httpx.URL(url)
|
|
268
|
+
return parsed.scheme, parsed.host, parsed.port or {"http": 80, "https": 443}.get(parsed.scheme)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _outcome(step: Callable[[], tuple[Status, str]]) -> tuple[Status, str]:
|
|
272
|
+
try:
|
|
273
|
+
return step()
|
|
274
|
+
except Exception as e:
|
|
275
|
+
return "failed", f"{type(e).__name__}: {e}"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _field_error(error: dict) -> str:
|
|
279
|
+
"""One of a ``ValidationError``'s errors as a person reads it: where, then what."""
|
|
280
|
+
where = ".".join(map(str, error.get("loc") or ())) or "the answer"
|
|
281
|
+
return f"{where}: {error.get('msg')}"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _holds_data(parts: list) -> bool:
|
|
285
|
+
"""Whether exported parts carry task data: a part that isn't a data part, or a data part with a value in it."""
|
|
286
|
+
return any(not (isinstance(p, dict) and p.get("kind") == "data") or _filled(p.get("data")) for p in parts)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _filled(value: object) -> bool:
|
|
290
|
+
if isinstance(value, (dict, list)):
|
|
291
|
+
return any(_filled(v) for v in (value.values() if isinstance(value, dict) else value))
|
|
292
|
+
return value not in (None, "")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _slash_toggled(routes: object) -> list[str]:
|
|
296
|
+
"""The first GET route without path parameters, with its trailing slash added or taken off: a path a server
|
|
297
|
+
may redirect to the route's own, which shows the address the redirect names."""
|
|
298
|
+
for route in routes if isinstance(routes, list) else []:
|
|
299
|
+
path = route.get("path") if isinstance(route, dict) and route.get("method") == "GET" else None
|
|
300
|
+
if isinstance(path, str) and path.startswith("/") and path != "/" and "{" not in path:
|
|
301
|
+
return [path[:-1] if path.endswith("/") else path + "/"]
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@contextlib.contextmanager
|
|
306
|
+
def _clock_server(host: str) -> Iterator[tuple[str, Callable[[], datetime.datetime]]]:
|
|
307
|
+
"""A gateway-shaped ``/clock/time`` running from ``CLOCK_START`` at real speed, and its time now."""
|
|
308
|
+
started = time.monotonic()
|
|
309
|
+
|
|
310
|
+
def virtual_now() -> datetime.datetime:
|
|
311
|
+
return CLOCK_START + datetime.timedelta(seconds=time.monotonic() - started)
|
|
312
|
+
|
|
313
|
+
class Clock(BaseHTTPRequestHandler):
|
|
314
|
+
def do_GET(self) -> None:
|
|
315
|
+
body = json.dumps({"virtual_time": iso(virtual_now())}).encode()
|
|
316
|
+
self.send_response(200)
|
|
317
|
+
self.send_header("Content-Type", "application/json")
|
|
318
|
+
self.send_header("Content-Length", str(len(body)))
|
|
319
|
+
self.end_headers()
|
|
320
|
+
self.wfile.write(body)
|
|
321
|
+
|
|
322
|
+
def log_message(self, *args) -> None:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
server = ThreadingHTTPServer(("127.0.0.1" if host in ("127.0.0.1", "localhost") else "0.0.0.0", 0), Clock)
|
|
326
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
327
|
+
try:
|
|
328
|
+
address = f"[{host}]" if ":" in host else host
|
|
329
|
+
yield f"http://{address}:{server.server_port}/clock/time", virtual_now
|
|
330
|
+
finally:
|
|
331
|
+
server.shutdown()
|
|
332
|
+
server.server_close()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
_MARK = {"passed": "PASS", "failed": "FAIL", "skipped": "----", "warned": "WARN"}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main(argv: list[str] | None = None) -> int:
|
|
339
|
+
parser = argparse.ArgumentParser(prog="python -m agentenv_protocol.website.conformance",
|
|
340
|
+
description="Check a running site's server against urn:agentenv:website/v1.")
|
|
341
|
+
parser.add_argument("url", help="where the server answers, such as http://localhost:8000")
|
|
342
|
+
parser.add_argument("--clock-host", default="127.0.0.1", help="the address the server reaches this checker at")
|
|
343
|
+
parser.add_argument("--timeout", type=float, default=30, help="seconds for each request")
|
|
344
|
+
args = parser.parse_args(argv)
|
|
345
|
+
try:
|
|
346
|
+
results = check(args.url, clock_host=args.clock_host, timeout=args.timeout)
|
|
347
|
+
except httpx.TransportError as e:
|
|
348
|
+
print(f"couldn't reach {args.url}: {type(e).__name__}: {e}", file=sys.stderr)
|
|
349
|
+
return 2
|
|
350
|
+
for result in results:
|
|
351
|
+
print(f"{_MARK[result.status]} {result.name}{': ' + result.detail if result.detail else ''}")
|
|
352
|
+
failed = sum(not r.passed for r in results)
|
|
353
|
+
print(f"{args.url}: {len(results) - failed} passed, warned or skipped, {failed} failed")
|
|
354
|
+
return 1 if failed else 0
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
sys.exit(main())
|