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.
Files changed (138) hide show
  1. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/PKG-INFO +32 -1
  2. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/README.md +31 -0
  3. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/pyproject.toml +1 -1
  4. agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/__init__.py +41 -0
  5. agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/clock.py +142 -0
  6. agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/conformance.py +358 -0
  7. agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/environment.py +431 -0
  8. agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/errors.py +39 -0
  9. agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/readiness.py +81 -0
  10. agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/routes.py +397 -0
  11. agentenv_framework_protocol-0.1.459/src/agentenv_protocol/website/session.py +55 -0
  12. agentenv_framework_protocol-0.1.459/tests/test_website_api.py +453 -0
  13. agentenv_framework_protocol-0.1.459/tests/test_website_conformance.py +331 -0
  14. agentenv_framework_protocol-0.1.459/tests/test_website_environment.py +720 -0
  15. agentenv_framework_protocol-0.1.459/tests/test_website_readiness.py +212 -0
  16. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/.gitignore +0 -0
  17. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/LICENSE +0 -0
  18. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/NOTICE +0 -0
  19. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/THIRD_PARTY_NOTICES.md +0 -0
  20. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/__init__.py +0 -0
  21. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/advanced_agent.py +0 -0
  22. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/basic_agent.py +0 -0
  23. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/custom_extensions_agent.py +0 -0
  24. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/multimodal_agent.py +0 -0
  25. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/reference_model.py +0 -0
  26. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/examples/streaming_agent.py +0 -0
  27. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/__init__.py +0 -0
  28. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  29. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  30. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  31. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  32. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  33. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  34. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
  35. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  36. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  37. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  38. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  39. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/agent_env_environment.py +0 -0
  40. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/browser_cdp.py +0 -0
  41. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/client.py +0 -0
  42. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/__init__.py +0 -0
  43. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/_declare.py +0 -0
  44. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/_linux.py +0 -0
  45. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/_load.py +0 -0
  46. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/_with_fds.py +0 -0
  47. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/app.py +0 -0
  48. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/app_data.py +0 -0
  49. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/app_runtime.py +0 -0
  50. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/apps.py +0 -0
  51. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/card.py +0 -0
  52. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/checks.py +0 -0
  53. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
  54. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
  55. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
  56. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/client.py +0 -0
  57. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
  58. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
  59. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
  60. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/conformance/report.py +0 -0
  61. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/dataplane.py +0 -0
  62. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/driver.py +0 -0
  63. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/errors.py +0 -0
  64. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/extensions.py +0 -0
  65. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/frames.py +0 -0
  66. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/keys.py +0 -0
  67. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/launch.py +0 -0
  68. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/manifest.py +0 -0
  69. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/operations.py +0 -0
  70. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/platform.py +0 -0
  71. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/queue.py +0 -0
  72. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/recording.py +0 -0
  73. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/results.py +0 -0
  74. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/run_install_step.py +0 -0
  75. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/runtime.py +0 -0
  76. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/seed.py +0 -0
  77. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/serve.py +0 -0
  78. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/serve_app.py +0 -0
  79. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/session.py +0 -0
  80. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/settings.py +0 -0
  81. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/skill.py +0 -0
  82. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/spaces.py +0 -0
  83. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/svc.py +0 -0
  84. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/computer/tools.py +0 -0
  85. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/errors.py +0 -0
  86. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/manifest.py +0 -0
  87. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/preflight.py +0 -0
  88. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/tool_access.py +0 -0
  89. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/tool_client.py +0 -0
  90. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/tool_context.py +0 -0
  91. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/transfers.py +0 -0
  92. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/types.py +0 -0
  93. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/src/agentenv_protocol/website_browser.py +0 -0
  94. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/computer_fake.py +0 -0
  95. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/probe_fake.py +0 -0
  96. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_agent.py +0 -0
  97. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_agent_examples.py +0 -0
  98. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_cdp_config.py +0 -0
  99. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_object_transfers.py +0 -0
  100. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_peer_agents.py +0 -0
  101. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_proxy.py +0 -0
  102. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_staging.py +0 -0
  103. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_a2a_trajectory_log.py +0 -0
  104. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_agent_env_environment.py +0 -0
  105. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_browser_cdp.py +0 -0
  106. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_client.py +0 -0
  107. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_app_data.py +0 -0
  108. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_app_server.py +0 -0
  109. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_conformance.py +0 -0
  110. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_conformance_checks.py +0 -0
  111. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_conformance_page.py +0 -0
  112. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_conformance_recording.py +0 -0
  113. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_dataplane.py +0 -0
  114. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_declare.py +0 -0
  115. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_driver.py +0 -0
  116. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_extensions.py +0 -0
  117. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_frames.py +0 -0
  118. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_page.py +0 -0
  119. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_queue.py +0 -0
  120. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_recording.py +0 -0
  121. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_seed.py +0 -0
  122. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_serve.py +0 -0
  123. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_spaces.py +0 -0
  124. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_computer_svc_forward.py +0 -0
  125. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_control_token.py +0 -0
  126. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_data_objects.py +0 -0
  127. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_errors.py +0 -0
  128. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_extension_methods.py +0 -0
  129. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_intake.py +0 -0
  130. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_manifest.py +0 -0
  131. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_preflight.py +0 -0
  132. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_access.py +0 -0
  133. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_annotations.py +0 -0
  134. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_client.py +0 -0
  135. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_context.py +0 -0
  136. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_context_fastmcp.py +0 -0
  137. {agentenv_framework_protocol-0.1.458 → agentenv_framework_protocol-0.1.459}/tests/test_tool_context_injection.py +0 -0
  138. {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.458
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.458"
3
+ version = "0.1.459"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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())