agentenv-framework-protocol 0.1.420__tar.gz → 0.1.421__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 (113) hide show
  1. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/PKG-INFO +22 -2
  2. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/README.md +21 -1
  3. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/pyproject.toml +1 -1
  4. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/__init__.py +2 -0
  5. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/agent_env_environment.py +24 -3
  6. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/client.py +62 -14
  7. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/conformance/__init__.py +5 -3
  8. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/conformance/__main__.py +8 -2
  9. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/conformance/checks.py +20 -1
  10. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/conformance/client.py +7 -5
  11. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/driver.py +2 -2
  12. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/serve_app.py +8 -4
  13. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/types.py +3 -0
  14. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_app_server.py +27 -2
  15. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_conformance.py +16 -2
  16. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_serve.py +6 -2
  17. agentenv_framework_protocol-0.1.421/tests/test_control_token.py +148 -0
  18. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/.gitignore +0 -0
  19. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/LICENSE +0 -0
  20. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/NOTICE +0 -0
  21. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/THIRD_PARTY_NOTICES.md +0 -0
  22. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/examples/__init__.py +0 -0
  23. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/examples/advanced_agent.py +0 -0
  24. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/examples/basic_agent.py +0 -0
  25. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/examples/custom_extensions_agent.py +0 -0
  26. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/examples/multimodal_agent.py +0 -0
  27. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/examples/reference_model.py +0 -0
  28. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/examples/streaming_agent.py +0 -0
  29. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  30. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  31. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  32. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  33. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  34. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  35. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
  36. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  37. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  38. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  39. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  40. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/__init__.py +0 -0
  41. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/_declare.py +0 -0
  42. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/_linux.py +0 -0
  43. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/_load.py +0 -0
  44. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/app.py +0 -0
  45. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/app_data.py +0 -0
  46. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/app_runtime.py +0 -0
  47. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/apps.py +0 -0
  48. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/card.py +0 -0
  49. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/checks.py +0 -0
  50. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
  51. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
  52. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/conformance/report.py +0 -0
  53. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/dataplane.py +0 -0
  54. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/errors.py +0 -0
  55. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/extensions.py +0 -0
  56. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/frames.py +0 -0
  57. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/keys.py +0 -0
  58. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/launch.py +0 -0
  59. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/manifest.py +0 -0
  60. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/operations.py +0 -0
  61. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/platform.py +0 -0
  62. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/queue.py +0 -0
  63. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/recording.py +0 -0
  64. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/results.py +0 -0
  65. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/run_install_step.py +0 -0
  66. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/runtime.py +0 -0
  67. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/serve.py +0 -0
  68. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/session.py +0 -0
  69. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/settings.py +0 -0
  70. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/skill.py +0 -0
  71. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/spaces.py +0 -0
  72. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/computer/tools.py +0 -0
  73. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/errors.py +0 -0
  74. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/manifest.py +0 -0
  75. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/preflight.py +0 -0
  76. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/tool_access.py +0 -0
  77. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/tool_client.py +0 -0
  78. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/tool_context.py +0 -0
  79. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/src/agentenv_protocol/transfers.py +0 -0
  80. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/computer_fake.py +0 -0
  81. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/probe_fake.py +0 -0
  82. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_a2a_agent.py +0 -0
  83. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_a2a_agent_examples.py +0 -0
  84. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_a2a_object_transfers.py +0 -0
  85. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_a2a_peer_agents.py +0 -0
  86. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_a2a_proxy.py +0 -0
  87. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_a2a_staging.py +0 -0
  88. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_a2a_trajectory_log.py +0 -0
  89. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_agent_env_environment.py +0 -0
  90. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_client.py +0 -0
  91. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_app_data.py +0 -0
  92. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_conformance_checks.py +0 -0
  93. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_conformance_recording.py +0 -0
  94. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_dataplane.py +0 -0
  95. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_declare.py +0 -0
  96. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_driver.py +0 -0
  97. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_extensions.py +0 -0
  98. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_frames.py +0 -0
  99. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_queue.py +0 -0
  100. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_recording.py +0 -0
  101. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_computer_spaces.py +0 -0
  102. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_data_objects.py +0 -0
  103. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_errors.py +0 -0
  104. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_extension_methods.py +0 -0
  105. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_intake.py +0 -0
  106. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_manifest.py +0 -0
  107. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_preflight.py +0 -0
  108. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_tool_access.py +0 -0
  109. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_tool_annotations.py +0 -0
  110. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_tool_client.py +0 -0
  111. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_tool_context.py +0 -0
  112. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_tool_context_fastmcp.py +0 -0
  113. {agentenv_framework_protocol-0.1.420 → agentenv_framework_protocol-0.1.421}/tests/test_tool_context_injection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentenv-framework-protocol
3
- Version: 0.1.420
3
+ Version: 0.1.421
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
@@ -283,6 +283,23 @@ async def display_get(self) -> dict: ...
283
283
  async def display_set(self, width: int, height: int) -> dict: ...
284
284
  ```
285
285
 
286
+ ### Framework-only routes and the control token
287
+
288
+ Everything an environment serves but its MCP endpoint and its card is agent-env's own: the data
289
+ plane, every `@extension` route and the tool-access routes. When agent-env deploys an environment
290
+ that takes one, it boots it with a per-deploy token in `AGENTENV_CONTROL_TOKEN`
291
+ (`CONTROL_TOKEN_ENV`), and the SDK then refuses a call to those routes that doesn't carry it as
292
+ `Authorization: Bearer <token>`, answering 401 with the code `unauthorized` before the route runs.
293
+ The MCP endpoint and the card stay open, so an agent reaches the environment's tools as before and
294
+ nothing else. Without the variable, as when the environment runs on its own, every route is open.
295
+ `control_guard()` wraps a route an environment adds itself the same way, as a computer driver does
296
+ its `/svc/<app>/` routes.
297
+
298
+ On the client side each call takes `headers`, and `client.control_headers(token)` builds them.
299
+ Code that names none runs under `client.authorized({env_url: client.control_headers(token)})`:
300
+ within it, a call to an address under `env_url` sends those headers. agent-env runs every task
301
+ step, a verifier's `verify` included, under such a grant for each environment it deployed.
302
+
286
303
  ### Tool access without a gateway
287
304
 
288
305
  An environment agents reach directly, with no gateway in front, can enforce per-role tool access
@@ -644,7 +661,10 @@ report = await run_probe("http://localhost:18765", "full") # a ProbeReport
644
661
  failed = [c for c in report.checks if c.status == "failed"]
645
662
  ```
646
663
 
647
- The CLI exits 0 when no check failed, 1 when one did and 2 when it can't reach the driver.
664
+ The CLI exits 0 when no check failed, 1 when one did and 2 when it can't reach the driver. A
665
+ driver booted with a control token is probed with it: `--token`, by default
666
+ `$AGENTENV_CONTROL_TOKEN`, or `run_probe(..., token=...)`. Given one, the probe also checks that
667
+ `computer-ready`'s status refuses a call without it and with a wrong one.
648
668
 
649
669
  | Level | Checks | Needs on the computer |
650
670
  |---|---|---|
@@ -227,6 +227,23 @@ async def display_get(self) -> dict: ...
227
227
  async def display_set(self, width: int, height: int) -> dict: ...
228
228
  ```
229
229
 
230
+ ### Framework-only routes and the control token
231
+
232
+ Everything an environment serves but its MCP endpoint and its card is agent-env's own: the data
233
+ plane, every `@extension` route and the tool-access routes. When agent-env deploys an environment
234
+ that takes one, it boots it with a per-deploy token in `AGENTENV_CONTROL_TOKEN`
235
+ (`CONTROL_TOKEN_ENV`), and the SDK then refuses a call to those routes that doesn't carry it as
236
+ `Authorization: Bearer <token>`, answering 401 with the code `unauthorized` before the route runs.
237
+ The MCP endpoint and the card stay open, so an agent reaches the environment's tools as before and
238
+ nothing else. Without the variable, as when the environment runs on its own, every route is open.
239
+ `control_guard()` wraps a route an environment adds itself the same way, as a computer driver does
240
+ its `/svc/<app>/` routes.
241
+
242
+ On the client side each call takes `headers`, and `client.control_headers(token)` builds them.
243
+ Code that names none runs under `client.authorized({env_url: client.control_headers(token)})`:
244
+ within it, a call to an address under `env_url` sends those headers. agent-env runs every task
245
+ step, a verifier's `verify` included, under such a grant for each environment it deployed.
246
+
230
247
  ### Tool access without a gateway
231
248
 
232
249
  An environment agents reach directly, with no gateway in front, can enforce per-role tool access
@@ -588,7 +605,10 @@ report = await run_probe("http://localhost:18765", "full") # a ProbeReport
588
605
  failed = [c for c in report.checks if c.status == "failed"]
589
606
  ```
590
607
 
591
- The CLI exits 0 when no check failed, 1 when one did and 2 when it can't reach the driver.
608
+ The CLI exits 0 when no check failed, 1 when one did and 2 when it can't reach the driver. A
609
+ driver booted with a control token is probed with it: `--token`, by default
610
+ `$AGENTENV_CONTROL_TOKEN`, or `run_probe(..., token=...)`. Given one, the probe also checks that
611
+ `computer-ready`'s status refuses a call without it and with a wrong one.
592
612
 
593
613
  | Level | Checks | Needs on the computer |
594
614
  |---|---|---|
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.420"
3
+ version = "0.1.421"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -53,6 +53,7 @@ from .manifest import (
53
53
  manifest_compatible,
54
54
  )
55
55
  from .types import (
56
+ CONTROL_TOKEN_ENV,
56
57
  DATA_OBJECTS_EXTENSION_URI,
57
58
  INTAKE_EXTENSION_URI,
58
59
  MCP_PATH,
@@ -161,6 +162,7 @@ __all__ = [
161
162
  "ExtensionCallError",
162
163
  "PROTOCOL_VERSION",
163
164
  "RPC_PATH",
165
+ "CONTROL_TOKEN_ENV",
164
166
  "WELL_KNOWN_PATH",
165
167
  "METHOD_RESET",
166
168
  "METHOD_ADD",
@@ -5,6 +5,7 @@ import datetime
5
5
  import decimal
6
6
  import enum
7
7
  import functools
8
+ import hmac
8
9
  import inspect
9
10
  import json
10
11
  import logging
@@ -28,7 +29,7 @@ from .tool_access import DISABLE as TOOL_DISABLE, ENABLE as TOOL_ENABLE, PATHS a
28
29
  from .tool_access import extensions as tool_access_extensions
29
30
  from .tool_context import EMPTY, ToolContext, bound, context_from_http, context_from_mcp
30
31
  from .transfers import WriteNamespaceGrant
31
- from .types import DATA_OBJECTS_EXTENSION_URI, MCP_TRANSPORT, SKILL_EXTENSION_URI, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body
32
+ from .types import CONTROL_TOKEN_ENV, DATA_OBJECTS_EXTENSION_URI, MCP_TRANSPORT, SKILL_EXTENSION_URI, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body
32
33
 
33
34
  logger = logging.getLogger(__name__)
34
35
 
@@ -343,10 +344,11 @@ class AgentEnvApplication(ABC):
343
344
  if isinstance(self.handler, AgentEnvEnvironment):
344
345
  self.handler._application = self
345
346
  self.environment_card = self._card_for(app)
346
- self._add_route(app, rpc_url, ["POST"], self._dispatch)
347
+ guard = control_guard()
348
+ self._add_route(app, rpc_url, ["POST"], guard(self._dispatch))
347
349
  self._add_route(app, card_url, ["GET"], self._serve_card)
348
350
  for path, method, handler in self._ext_routes:
349
- self._add_route(app, path, [method], handler)
351
+ self._add_route(app, path, [method], guard(handler))
350
352
  for descriptor, fn in self._tools:
351
353
  self._register_tool(app, descriptor, fn)
352
354
  for resource in self._resources:
@@ -1544,6 +1546,25 @@ def _error_response(error: AgentEnvError) -> Response:
1544
1546
  return _json_response(error_body(**error.fields()), error.status)
1545
1547
 
1546
1548
 
1549
+ def control_guard() -> Callable[[Callable], Callable]:
1550
+ """What wraps a framework-only route, everything an env serves but its MCP endpoint and its card: with
1551
+ ``CONTROL_TOKEN_ENV`` set, a call that doesn't carry that token as its bearer is refused before the route runs;
1552
+ without it, nothing."""
1553
+ token = os.environ.get(CONTROL_TOKEN_ENV)
1554
+ if not token:
1555
+ return lambda handler: handler
1556
+ expected = f"Bearer {token}".encode()
1557
+
1558
+ def guard(handler: Callable) -> Callable:
1559
+ async def guarded(request: Request) -> Response:
1560
+ if not hmac.compare_digest(request.headers.get("authorization", "").encode(), expected):
1561
+ return _error_response(AgentEnvError(
1562
+ "unauthorized", "this route is agent-env's own: it takes the env's control token", status=401))
1563
+ return await handler(request)
1564
+ return guarded
1565
+ return guard
1566
+
1567
+
1547
1568
  def _tool_access_handler(access: ToolAccess, action: str) -> Callable:
1548
1569
  async def handler(request: Request) -> Response:
1549
1570
  raw = await request.body()
@@ -1,7 +1,12 @@
1
- """Data-plane wire protocol client (JSON-RPC) for servers implementing the AgentEnv protocol."""
1
+ """Data-plane wire protocol client (JSON-RPC) for servers implementing the AgentEnv protocol. A call to an env's
2
+ framework-only routes carries its control token: as the call's ``headers`` (``control_headers``), or, for code that
3
+ names none, from the grant it runs under (``authorized``)."""
2
4
  from __future__ import annotations
3
5
 
6
+ import contextlib
4
7
  import logging
8
+ from collections.abc import Iterator
9
+ from contextvars import ContextVar
5
10
 
6
11
  import httpx
7
12
 
@@ -11,6 +16,9 @@ from .types import INTAKE_EXTENSION_URI, MCP_PATH, MCP_TRANSPORT, METHOD_ADD, ME
11
16
 
12
17
  logger = logging.getLogger(__name__)
13
18
 
19
+ #: The headers that carry the control token of each env address the running code is granted, by address.
20
+ _GRANTS: ContextVar[dict[str, dict[str, str]]] = ContextVar("agentenv_control_grants", default={})
21
+
14
22
 
15
23
  class ExtensionCallError(httpx.HTTPStatusError):
16
24
  """A non-2xx answer from an extension method, with the error fields the server sent. A body without them
@@ -35,10 +43,45 @@ def _error_fields(response: httpx.Response) -> dict:
35
43
  return error if isinstance(error, dict) else {}
36
44
 
37
45
 
38
- async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool) -> dict:
46
+ def control_headers(token: str | None) -> dict[str, str]:
47
+ """The headers that carry an env's control token to its framework-only routes; none without one."""
48
+ return {"Authorization": f"Bearer {token}"} if token else {}
49
+
50
+
51
+ @contextlib.contextmanager
52
+ def authorized(grants: dict[str | None, dict[str, str]]) -> Iterator[None]:
53
+ """Within it, a call to an address under one of ``grants``, an env's address and the headers that carry its
54
+ control token (``control_headers``), sends those headers when the call names none of its own: how the steps
55
+ agent-env runs, and the verifiers they call, reach an env's framework-only routes as written."""
56
+ granted = _GRANTS.set({**_GRANTS.get(),
57
+ **{url.rstrip("/"): sent for url, sent in grants.items() if url and sent}})
58
+ try:
59
+ yield
60
+ finally:
61
+ _GRANTS.reset(granted)
62
+
63
+
64
+ def request_headers(url: str, headers: dict[str, str] | None = None) -> dict[str, str]:
65
+ """What a call to ``url`` sends: ``headers`` when it names them, else the headers its grant gives
66
+ (``authorized``), if any."""
67
+ if headers is not None:
68
+ return headers
69
+ return granted_headers(url, _GRANTS.get())
70
+
71
+
72
+ def granted_headers(url: str, grants: dict[str | None, dict[str, str]]) -> dict[str, str]:
73
+ """What ``grants``, env addresses and the headers that carry their control tokens, give a call to ``url``: those of
74
+ the address it is or is under, and none anywhere else."""
75
+ return next((sent for base, sent in grants.items()
76
+ if base and (url == base.rstrip("/") or url.startswith(f"{base.rstrip('/')}/"))), {})
77
+
78
+
79
+ async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool,
80
+ headers: dict[str, str] | None) -> dict:
39
81
  request = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
82
+ url = f"{base_url}{RPC_PATH}"
40
83
  async with httpx.AsyncClient(verify=verify) as client:
41
- response = await client.post(f"{base_url}{RPC_PATH}", json=request, timeout=timeout)
84
+ response = await client.post(url, json=request, timeout=timeout, headers=request_headers(url, headers))
42
85
  response.raise_for_status()
43
86
  body = response.json()
44
87
  if "error" in body:
@@ -47,18 +90,20 @@ async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: b
47
90
  return body["result"]
48
91
 
49
92
 
50
- async def reset_data(base_url: str, timeout: int = 30, verify: bool = True) -> ResetDataResponse:
51
- return ResetDataResponse.model_validate(await _rpc(base_url, METHOD_RESET, {}, timeout, verify))
93
+ async def reset_data(base_url: str, timeout: int = 30, verify: bool = True, *,
94
+ headers: dict[str, str] | None = None) -> ResetDataResponse:
95
+ return ResetDataResponse.model_validate(await _rpc(base_url, METHOD_RESET, {}, timeout, verify, headers))
52
96
 
53
97
 
54
- async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify: bool = True) -> AddDataResponse:
98
+ async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify: bool = True, *,
99
+ headers: dict[str, str] | None = None) -> AddDataResponse:
55
100
  params = {"parts": [p.model_dump(mode="json", exclude_none=True) for p in parts]}
56
- return AddDataResponse.model_validate(await _rpc(base_url, METHOD_ADD, params, timeout, verify))
101
+ return AddDataResponse.model_validate(await _rpc(base_url, METHOD_ADD, params, timeout, verify, headers))
57
102
 
58
103
 
59
104
  async def get_data(
60
105
  base_url: str, timeout: int = 30, verify: bool = True, *, write_namespace: WriteNamespaceGrant | None = None,
61
- paths: list[str] | None = None,
106
+ paths: list[str] | None = None, headers: dict[str, str] | None = None,
62
107
  ) -> GetDataResponse:
63
108
  """`data/get`. A server advertising `DATA_OBJECTS_EXTENSION_URI` may be handed `write_namespace` to upload
64
109
  its export under; `uploaded_object_path` reads from the answer where it did. `paths` names what a computer
@@ -66,7 +111,7 @@ async def get_data(
66
111
  params = {} if write_namespace is None else {"write_namespace": write_namespace.model_dump(mode="json", exclude_none=True)}
67
112
  if paths is not None:
68
113
  params["paths"] = paths
69
- return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, params, timeout, verify))
114
+ return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, params, timeout, verify, headers))
70
115
 
71
116
 
72
117
  async def get_card(base_url: str, timeout: int = 10, verify: bool = True) -> dict:
@@ -180,7 +225,8 @@ def find_child(card: dict, name: str) -> dict | None:
180
225
  return None
181
226
 
182
227
 
183
- async def get_interface_manifest(base_url: str, interface: str, endpoint: str = INTERFACE_MANIFEST_PATH, timeout: int = 30, verify: bool = True):
228
+ async def get_interface_manifest(base_url: str, interface: str, endpoint: str = INTERFACE_MANIFEST_PATH, timeout: int = 30, verify: bool = True, *,
229
+ headers: dict[str, str] | None = None):
184
230
  """Fetch one interface's manifest from a server's manifest index endpoint.
185
231
 
186
232
  ``endpoint`` is the index path — pass the one advertised on the card under
@@ -191,12 +237,13 @@ async def get_interface_manifest(base_url: str, interface: str, endpoint: str =
191
237
  """
192
238
  url = f"{base_url.rstrip('/')}{endpoint}/{interface}"
193
239
  async with httpx.AsyncClient(verify=verify) as client:
194
- response = await client.get(url, timeout=timeout)
240
+ response = await client.get(url, timeout=timeout, headers=request_headers(url, headers))
195
241
  response.raise_for_status()
196
242
  return response.json()
197
243
 
198
244
 
199
- async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | None = None, timeout: int = 30, verify: bool = True, *, method: str | None = None):
245
+ async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | None = None, timeout: int = 30, verify: bool = True, *, method: str | None = None,
246
+ headers: dict[str, str] | None = None):
200
247
  """Invoke a card extension via its advertised REST endpoint.
201
248
 
202
249
  Reads the endpoint and HTTP verb of the extension method named `method` from the card, so the
@@ -215,11 +262,12 @@ async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | N
215
262
  if not op["endpoint"]:
216
263
  raise ValueError(f"extension {uri} has no endpoint in params")
217
264
  url = f"{base_url}{op['endpoint']}"
265
+ sent = request_headers(url, headers)
218
266
  async with httpx.AsyncClient(verify=verify) as client:
219
267
  if op["method"] == "GET":
220
- response = await client.get(url, params=params or {}, timeout=timeout)
268
+ response = await client.get(url, params=params or {}, timeout=timeout, headers=sent)
221
269
  else:
222
- response = await client.request(op["method"], url, json=params or {}, timeout=timeout)
270
+ response = await client.request(op["method"], url, json=params or {}, timeout=timeout, headers=sent)
223
271
  if response.status_code >= 400:
224
272
  raise ExtensionCallError(response)
225
273
  return response.json()
@@ -11,9 +11,11 @@ from .report import CheckResult, Level, ProbeReport, Status, Verdict
11
11
  __all__ = ["run_probe", "ProbeReport", "CheckResult", "Level", "Status", "Verdict"]
12
12
 
13
13
 
14
- async def run_probe(url: str, level: Level = "full", *, timeout_s: float = 60, ready_timeout_s: float = 60) -> ProbeReport:
15
- """Probe the driver serving its card at ``url``. ``ready_timeout_s`` is how long it may take to report ready."""
16
- driver = await Driver.connect(url, timeout_s)
14
+ async def run_probe(url: str, level: Level = "full", *, timeout_s: float = 60, ready_timeout_s: float = 60,
15
+ token: str | None = None) -> ProbeReport:
16
+ """Probe the driver serving its card at ``url``, sending ``token``, its control token, to its framework-only routes.
17
+ ``ready_timeout_s`` is how long it may take to report ready."""
18
+ driver = await Driver.connect(url, timeout_s, token)
17
19
  async with driver.session() as session:
18
20
  probe = Probe(driver, session, ready_timeout_s)
19
21
  await probe.basic()
@@ -1,12 +1,15 @@
1
1
  """``python -m agentenv_protocol.computer.conformance <driver url>``: probe a running driver, print each check, and
2
- exit 1 when one failed or 2 when the driver couldn't be reached."""
2
+ exit 1 when one failed or 2 when the driver couldn't be reached. ``--token`` is its control token, by default
3
+ ``CONTROL_TOKEN_ENV``'s."""
3
4
  from __future__ import annotations
4
5
 
5
6
  import argparse
6
7
  import asyncio
8
+ import os
7
9
  import sys
8
10
  from pathlib import Path
9
11
 
12
+ from ...types import CONTROL_TOKEN_ENV
10
13
  from . import run_probe
11
14
 
12
15
  _MARK = {"passed": "PASS", "failed": "FAIL", "unverified": "----", "not_applicable": "n/a "}
@@ -19,9 +22,12 @@ def main(argv: list[str] | None = None) -> int:
19
22
  parser.add_argument("--level", choices=("basic", "full"), default="full")
20
23
  parser.add_argument("--json", type=Path, help="also write the report to this file")
21
24
  parser.add_argument("--timeout", type=float, default=60, help="seconds for each call, and to report ready")
25
+ parser.add_argument("--token", default=os.environ.get(CONTROL_TOKEN_ENV),
26
+ help=f"the driver's control token, if it was given one (default: ${CONTROL_TOKEN_ENV})")
22
27
  args = parser.parse_args(argv)
23
28
  try:
24
- report = asyncio.run(run_probe(args.url, args.level, timeout_s=args.timeout, ready_timeout_s=args.timeout))
29
+ report = asyncio.run(run_probe(args.url, args.level, timeout_s=args.timeout, ready_timeout_s=args.timeout,
30
+ token=args.token))
25
31
  except Exception as e:
26
32
  print(f"couldn't probe {args.url}: {type(e).__name__}: {e}", file=sys.stderr)
27
33
  return 2
@@ -88,7 +88,8 @@ class Probe:
88
88
  for check, section in (("P0", self._ready), ("P1", self._card), ("P2", self._frame), ("P5", self._refusals),
89
89
  ("X", self._display), ("X", self._spaces), ("X", self._keyboard_settings),
90
90
  ("X", self._exec), ("X", self._data_get), ("X", self._app_exports),
91
- ("X", self._tool_access), ("X", self._pointer), ("X", self._skill)):
91
+ ("X", self._tool_access), ("X", self._pointer), ("X", self._skill),
92
+ ("X", self._control)):
92
93
  await self._guarded(check, section)
93
94
  self._grants()
94
95
 
@@ -422,6 +423,24 @@ class Probe:
422
423
  await self.check("X", f"app {app.get('name')}'s data/get answers its parts through the driver", export,
423
424
  *DATA_PLANE)
424
425
 
426
+ async def _control(self) -> None:
427
+ if not self.driver.headers:
428
+ return
429
+
430
+ async def refused() -> str | None:
431
+ for sent, without in (({}, "without"), (protocol.control_headers("not-its-token"), "with a wrong")):
432
+ try:
433
+ await protocol.invoke_extension(self.driver.url, self.driver.card, COMPUTER_READY, method="status",
434
+ timeout=int(self.driver.timeout_s), headers=sent)
435
+ except ExtensionCallError as e:
436
+ if e.response.status_code != 401:
437
+ return f"computer-ready status answered a call {without} control token {e.response.status_code}"
438
+ continue
439
+ return f"computer-ready status answered a call {without} control token"
440
+ return None
441
+
442
+ await self.check("X", "a framework-only route refuses a call without the control token", refused)
443
+
425
444
  async def _tool_access(self) -> None:
426
445
  if not (self.driver.declares(DISABLE_TOOL_EXTENSION_URI) and self.driver.declares(ENABLE_TOOL_EXTENSION_URI)):
427
446
  return
@@ -24,14 +24,15 @@ STREAM_READ_S = 300
24
24
 
25
25
 
26
26
  class Driver:
27
- def __init__(self, url: str, card: dict, timeout_s: float) -> None:
27
+ def __init__(self, url: str, card: dict, timeout_s: float, token: str | None = None) -> None:
28
28
  self.url = url.rstrip("/")
29
29
  self.card = card
30
30
  self.timeout_s = timeout_s
31
+ self.headers = protocol.control_headers(token) if token else None
31
32
 
32
33
  @classmethod
33
- async def connect(cls, url: str, timeout_s: float) -> Driver:
34
- return cls(url, await protocol.get_card(url.rstrip("/"), timeout=int(timeout_s)), timeout_s)
34
+ async def connect(cls, url: str, timeout_s: float, token: str | None = None) -> Driver:
35
+ return cls(url, await protocol.get_card(url.rstrip("/"), timeout=int(timeout_s)), timeout_s, token)
35
36
 
36
37
  @property
37
38
  def name(self) -> str:
@@ -59,12 +60,13 @@ class Driver:
59
60
 
60
61
  async def invoke(self, uri: str, method: str, params: dict | None = None) -> Any:
61
62
  return await protocol.invoke_extension(self.url, self.card, uri, params or {}, method=method,
62
- timeout=int(self.timeout_s))
63
+ timeout=int(self.timeout_s), headers=self.headers)
63
64
 
64
65
  async def rpc(self, method: str, params: dict | None = None, card: dict | None = None) -> dict:
65
66
  """``method``'s answer from the data plane of ``card``, the driver's own or a child's it fronts."""
66
67
  async with httpx.AsyncClient(timeout=self.timeout_s) as http:
67
- response = await http.post(self.url + ((card or self.card).get("url") or "/agentenv"),
68
+ url = self.url + ((card or self.card).get("url") or "/agentenv")
69
+ response = await http.post(url, headers=protocol.request_headers(url, self.headers),
68
70
  json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}})
69
71
  return response.json()
70
72
 
@@ -12,7 +12,7 @@ from starlette.requests import Request
12
12
  from starlette.responses import Response
13
13
 
14
14
  from ..agent_env_environment import (_CARD_CONFIG_ATTR, AgentEnvEnvironment, AgentEnvFastMCPApplication,
15
- _error_response, environment_card)
15
+ _error_response, control_guard, environment_card)
16
16
  from ..tool_context import ToolContext
17
17
  from ..types import EnvironmentCard, EnvironmentExtension, EnvironmentTool
18
18
  from . import _linux, apps, card
@@ -135,7 +135,7 @@ class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironmen
135
135
  return await apps.children(self._computer.apps) if self._computer.apps else None
136
136
 
137
137
  def _computer_front_apps(self, app: Any) -> Any:
138
- app.custom_route("/svc/{name}/{path:path}", methods=["GET", "POST"])(self._computer_forward)
138
+ app.custom_route("/svc/{name}/{path:path}", methods=["GET", "POST"])(control_guard()(self._computer_forward))
139
139
  return app
140
140
 
141
141
  async def _computer_forward(self, request: Request) -> Response:
@@ -1,19 +1,22 @@
1
1
  """Serve a computer app's class from its file, as its computer's program starts it:
2
2
  ``python -m agentenv_protocol.computer.serve_app <file>:<Class>``, run in the app's folder, serves the class's card and
3
- ``computer-app/v1`` on the Unix socket ``.agentenv/server.sock`` there. Before it serves, it keeps a copy of the app's
4
- data paths as installed, which a reset puts back; once it serves, it adds the app's launcher and launches a boot app
5
- (``AppRuntime.start``). A server that can't start, or whose start fails once it serves,
6
- writes why to ``.agentenv/server.failure``, which its computer's driver reports, and exits. Needs the ``app`` extra."""
3
+ ``computer-app/v1`` on the Unix socket ``.agentenv/server.sock`` there, which only the computer reaches, so it takes no
4
+ control token (``CONTROL_TOKEN_ENV``): the driver checks it in front of the app. Before it serves, it keeps a copy of
5
+ the app's data paths as installed, which a reset puts back; once it serves, it adds the app's launcher and launches a
6
+ boot app (``AppRuntime.start``). A server that can't start, or whose start fails once it serves, writes why to
7
+ ``.agentenv/server.failure``, which its computer's driver reports, and exits. Needs the ``app`` extra."""
7
8
  from __future__ import annotations
8
9
 
9
10
  import asyncio
10
11
  import contextlib
12
+ import os
11
13
  import sys
12
14
  import traceback
13
15
  from pathlib import Path
14
16
 
15
17
  import uvicorn
16
18
 
19
+ from ..types import CONTROL_TOKEN_ENV
17
20
  from ._load import load_class
18
21
  from .app import FAILURE, SOCKET, ComputerAppEnvironment
19
22
 
@@ -35,6 +38,7 @@ def _serve(argv: list[str]) -> None:
35
38
  if len(argv) != 1 or ":" not in argv[0]:
36
39
  raise SystemExit(USAGE)
37
40
  file, _, name = argv[0].partition(":")
41
+ os.environ.pop(CONTROL_TOKEN_ENV, None)
38
42
  app = load_class(Path(file), name, ComputerAppEnvironment)()
39
43
  runtime = app._computer_app_runtime()
40
44
  app._computer_app_data_plane().keep_initial()
@@ -16,6 +16,9 @@ PROTOCOL_VERSION = "1.0"
16
16
 
17
17
  WELL_KNOWN_PATH = "/.well-known/agent-env.json"
18
18
  RPC_PATH = "/agentenv"
19
+ #: The variable that hands an env the token its framework-only routes take as their bearer, everything it serves but
20
+ #: its MCP endpoint and its card; agent-env sets it when it deploys the env, and without it those routes are open.
21
+ CONTROL_TOKEN_ENV = "AGENTENV_CONTROL_TOKEN"
19
22
 
20
23
  # A card declares its MCP endpoint as an `additionalInterfaces` entry with this transport; a card
21
24
  # without one is reached at MCP_PATH by convention.
@@ -1,6 +1,7 @@
1
1
  """A computer app's own server: it adds the app's launcher, launches a boot app once per boot and says whether the app
2
2
  is ready (``computer-app/v1``); its computer's driver finds it on the socket in the app's folder and asks it, lists it
3
- as a child environment and forwards its requests to it, and leaves the paths it owns out of its own data/get."""
3
+ as a child environment and forwards its requests to it, only with the computer's control token when it has one, and
4
+ leaves the paths it owns out of its own data/get."""
4
5
  from __future__ import annotations
5
6
 
6
7
  import asyncio
@@ -31,7 +32,7 @@ from agentenv_protocol.computer import (ComputerAppEnvironment, Launch, ReadyChe
31
32
  from agentenv_protocol.computer import app, app_runtime, apps, session # noqa: E402
32
33
  from agentenv_protocol.computer.app import COMPUTER_APP, FAILURE, SOCKET # noqa: E402
33
34
  from agentenv_protocol.computer.platform import Platform # noqa: E402
34
- from agentenv_protocol.types import DATA_OBJECTS_EXTENSION_URI, WELL_KNOWN_PATH # noqa: E402
35
+ from agentenv_protocol.types import CONTROL_TOKEN_ENV, DATA_OBJECTS_EXTENSION_URI, WELL_KNOWN_PATH # noqa: E402
35
36
 
36
37
 
37
38
 
@@ -311,6 +312,30 @@ async def test_the_driver_lists_its_apps_as_children_forwards_each_ones_requests
311
312
  "Documents/note.txt"]
312
313
 
313
314
 
315
+ @pytest.mark.asyncio
316
+ async def test_with_a_control_token_the_driver_lists_its_apps_and_forwards_to_one_only_with_it(tmp_path, monkeypatch):
317
+ from computer_fake import FakeDriver, http_client
318
+
319
+ monkeypatch.setenv("HOME", str(tmp_path))
320
+ (tmp_path / "Mail").mkdir()
321
+ (tmp_path / "Mail/inbox.txt").write_text("hi")
322
+ get = {"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": {}}
323
+ with tempfile.TemporaryDirectory(dir="/tmp", prefix="apps-") as root:
324
+ monkeypatch.setattr(apps, "APPS", Path(root))
325
+ async with _serving(Inbox(), Path(root) / "mail", monkeypatch):
326
+ monkeypatch.setenv(CONTROL_TOKEN_ENV, "s3cret")
327
+ async with http_client(FakeDriver()) as http:
328
+ card = (await http.get(WELL_KNOWN_PATH)).json()
329
+ refused = [await http.post("/svc/mail/agentenv", json=get, headers=headers)
330
+ for headers in ({}, client.control_headers("guess"))]
331
+ forwarded = await http.post("/svc/mail/agentenv", json=get, headers=client.control_headers("s3cret"))
332
+
333
+ assert [child["name"] for child in card["children_environments"]] == ["mail"]
334
+ assert [answer.status_code for answer in refused] == [401, 401]
335
+ assert [part["metadata"][DATA_OBJECTS_EXTENSION_URI]["path"] for part in forwarded.json()["result"]["parts"]] == [
336
+ "Mail/inbox.txt"]
337
+
338
+
314
339
  @pytest.mark.asyncio
315
340
  async def test_the_session_launches_an_app_in_a_session_of_its_own_from_its_folder(tmp_path, monkeypatch):
316
341
  monkeypatch.setenv("DISPLAY", ":99")
@@ -1,6 +1,7 @@
1
1
  """The conformance probe against fake drivers on loopback: the fake driver with the fake fixture passes every check; a
2
- driver without what full needs gets those checks unverified; a driver broken one way fails the check for that way; each
3
- app a driver fronts that exports is asked for its export once; the report's verdicts and the CLI."""
2
+ driver given a control token passes with it, refusing calls without it, and fails without it; a driver without what
3
+ full needs gets those checks unverified; a driver broken one way fails the check for that way; each app a driver
4
+ fronts that exports is asked for its export once; the report's verdicts and the CLI."""
4
5
  from __future__ import annotations
5
6
 
6
7
  import json
@@ -33,6 +34,7 @@ from agentenv_protocol.computer.conformance import CheckResult, ProbeReport, run
33
34
  from agentenv_protocol.computer.conformance import checks # noqa: E402
34
35
  from agentenv_protocol.computer.conformance.client import Driver # noqa: E402
35
36
  from agentenv_protocol.computer.conformance.__main__ import main # noqa: E402
37
+ from agentenv_protocol import CONTROL_TOKEN_ENV # noqa: E402
36
38
  from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI # noqa: E402
37
39
  from agentenv_protocol.types import EnvironmentCard # noqa: E402
38
40
 
@@ -71,6 +73,18 @@ async def test_the_fake_driver_passes_every_check_at_full_and_cleans_up(home):
71
73
  assert ["kill", "4242"] in driver.commands
72
74
 
73
75
 
76
+ @pytest.mark.asyncio
77
+ async def test_a_driver_given_a_control_token_passes_with_it_and_refuses_calls_without_it(monkeypatch):
78
+ monkeypatch.setenv(CONTROL_TOKEN_ENV, "s3cret")
79
+
80
+ report = await _probe(FixtureDriver(), "basic", token="s3cret")
81
+ unauthorized = await _probe(FixtureDriver(), "basic")
82
+
83
+ assert report.passed, _failed(report)
84
+ assert _status(report, "a framework-only route refuses a call without the control token") == "passed"
85
+ assert not unauthorized.passed and "401 unauthorized" in _failed(unauthorized)[0]
86
+
87
+
74
88
  @pytest.mark.asyncio
75
89
  async def test_a_driver_whose_default_space_is_norm_passes_at_full():
76
90
  report = await _probe(FixtureDriver(settings=ComputerSettings(default_space="norm")))
@@ -3,6 +3,7 @@ that isn't such a class exits saying why, and an app's server that can't start s
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import os
6
7
  import sys
7
8
 
8
9
  import pytest
@@ -11,6 +12,7 @@ pytest.importorskip("mcp.server.fastmcp")
11
12
  pytest.importorskip("PIL")
12
13
  pytest.importorskip("uvicorn")
13
14
 
15
+ from agentenv_protocol import CONTROL_TOKEN_ENV # noqa: E402
14
16
  from agentenv_protocol.computer import ComputerDriverEnvironment, run_install_step, serve_app # noqa: E402
15
17
  from agentenv_protocol.computer.app import BUILD_SETTINGS, FAILURE, SOCKET # noqa: E402
16
18
  from agentenv_protocol.computer.app_runtime import AppRuntime # noqa: E402
@@ -164,10 +166,11 @@ def app_folder(tmp_path, monkeypatch, isolated):
164
166
  return tmp_path, ran
165
167
 
166
168
 
167
- def test_an_apps_server_serves_on_the_socket_in_its_folder_and_starts_the_app_once_it_serves(app_folder,
168
- monkeypatch):
169
+ def test_an_apps_server_serves_on_the_socket_in_its_folder_with_no_control_token_and_starts_the_app_once_it_serves(
170
+ app_folder, monkeypatch):
169
171
  folder, ran = app_folder
170
172
  started = []
173
+ monkeypatch.setenv(CONTROL_TOKEN_ENV, "s3cret")
171
174
 
172
175
  async def start(runtime):
173
176
  started.append(runtime)
@@ -177,6 +180,7 @@ def test_an_apps_server_serves_on_the_socket_in_its_folder_and_starts_the_app_on
177
180
  serve_app.main(["app.py:Notes"])
178
181
 
179
182
  (server,) = ran
183
+ assert CONTROL_TOKEN_ENV not in os.environ
180
184
  assert server.config.uds == str(SOCKET) and not server.should_exit
181
185
  assert not (folder / SOCKET).exists() and not (folder / FAILURE).exists()
182
186
  assert len(started) == 1 and isinstance(started[0], AppRuntime)
@@ -0,0 +1,148 @@
1
+ """The control token: with CONTROL_TOKEN_ENV set when an env mounts, everything it serves but its MCP endpoint and its
2
+ card refuses a call without that token, or with another, before the route runs, and answers one with it; the MCP
3
+ endpoint and the card stay open, and without the variable every route is open. The client sends a call's own headers,
4
+ or else the headers a grant gives the address it calls."""
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+
9
+ import anyio
10
+ import httpx
11
+ import pytest
12
+
13
+ pytest.importorskip("mcp.server.fastmcp")
14
+
15
+ from mcp import ClientSession # noqa: E402
16
+ from mcp.client import streamable_http as _streamable_http # noqa: E402
17
+ from mcp.server.fastmcp import FastMCP # noqa: E402
18
+
19
+ from agentenv_protocol import ( # noqa: E402
20
+ CONTROL_TOKEN_ENV,
21
+ RPC_PATH,
22
+ WELL_KNOWN_PATH,
23
+ AgentEnvEnvironment,
24
+ add_data,
25
+ client,
26
+ environment_card,
27
+ extension,
28
+ tool,
29
+ )
30
+
31
+ pytestmark = pytest.mark.skipif(not hasattr(_streamable_http, "streamable_http_client"),
32
+ reason="mcp without streamable_http_client")
33
+
34
+ _BASE = "http://127.0.0.1:8000"
35
+ RAN: list[str] = []
36
+
37
+
38
+ @environment_card(name="desk")
39
+ class _Desk(AgentEnvEnvironment):
40
+ tool_access = True
41
+
42
+ @tool()
43
+ def read(self) -> str:
44
+ return "read"
45
+
46
+ @add_data
47
+ async def load(self, parts: list) -> None:
48
+ RAN.append("data/add")
49
+
50
+ @extension("urn:agentenv:test-exec/v1", op="run")
51
+ async def run(self, argv: list) -> dict:
52
+ RAN.append("run")
53
+ return {"ran": argv}
54
+
55
+
56
+ @contextlib.asynccontextmanager
57
+ async def _served():
58
+ """An ``_Desk`` mounted on mcp's FastMCP as the environment's variables stand, and its REST and MCP clients."""
59
+ app = FastMCP("desk", json_response=True)
60
+ _Desk().mount(app)
61
+ asgi = app.streamable_http_app()
62
+ async with app.session_manager.run():
63
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url=_BASE) as rest:
64
+ async with _streamable_http.streamable_http_client(f"{_BASE}/mcp", http_client=rest) as (read, write, _):
65
+ async with ClientSession(read, write) as mcp:
66
+ await mcp.initialize()
67
+ yield rest, mcp
68
+
69
+
70
+ async def _calls(rest: httpx.AsyncClient, headers: dict) -> list[int]:
71
+ """The status of a call to each framework-only route with ``headers``: the data plane, an extension, tool access."""
72
+ load = {"jsonrpc": "2.0", "id": 1, "method": "data/add", "params": {"parts": [{"kind": "data", "data": {}}]}}
73
+ return [(await rest.post(RPC_PATH, json=load, headers=headers)).status_code,
74
+ (await rest.post("/agentenv/ext/test-exec/run", json={"argv": ["ls"]}, headers=headers)).status_code,
75
+ (await rest.post("/tools/disable", json={"role": "agent", "tools": ["read"]}, headers=headers)).status_code]
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_with_a_token_every_framework_only_route_refuses_a_call_without_it_before_it_runs(monkeypatch):
80
+ monkeypatch.setenv(CONTROL_TOKEN_ENV, "s3cret")
81
+ RAN.clear()
82
+
83
+ with anyio.fail_after(30):
84
+ async with _served() as (rest, mcp):
85
+ without = await _calls(rest, {})
86
+ wrong = await _calls(rest, client.control_headers("guess"))
87
+ refusal = (await rest.post("/agentenv/ext/test-exec/run", json={"argv": ["ls"]})).json()
88
+ ran_before = list(RAN)
89
+ right = await _calls(rest, client.control_headers("s3cret"))
90
+ card = await rest.get(WELL_KNOWN_PATH)
91
+ tools = [t.name for t in (await mcp.list_tools()).tools]
92
+
93
+ assert without == wrong == [401, 401, 401] and ran_before == []
94
+ assert refusal["error"]["code"] == "unauthorized" and refusal["error"]["applied"] == "no"
95
+ assert right == [200, 200, 200] and RAN == ["data/add", "run"]
96
+ assert card.status_code == 200 and tools == ["read"]
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_without_a_token_every_route_is_open(monkeypatch):
101
+ monkeypatch.delenv(CONTROL_TOKEN_ENV, raising=False)
102
+
103
+ with anyio.fail_after(30):
104
+ async with _served() as (rest, _):
105
+ assert await _calls(rest, {}) == [200, 200, 200]
106
+
107
+
108
+ def test_a_call_sends_its_own_headers_or_else_the_ones_its_grant_gives_an_address_under_the_envs():
109
+ env = "http://desk:18765"
110
+
111
+ with client.authorized({env: client.control_headers("s3cret"), "http://other:1": {},
112
+ None: client.control_headers("orphan")}):
113
+ granted = [client.request_headers(url) for url in (env, f"{env}/svc/mail/agentenv", f"{env}0/x",
114
+ "http://other:1/x")]
115
+ own = client.request_headers(f"{env}/agentenv", {"X-Mine": "1"})
116
+ with client.authorized({env: client.control_headers("rotated")}):
117
+ inner = client.request_headers(f"{env}/agentenv")
118
+ outer = client.request_headers(f"{env}/agentenv")
119
+ after = client.request_headers(f"{env}/agentenv")
120
+
121
+ bearer = {"Authorization": "Bearer s3cret"}
122
+ assert granted == [bearer, bearer, {}, {}] and own == {"X-Mine": "1"}
123
+ assert inner == {"Authorization": "Bearer rotated"} and outer == bearer and after == {}
124
+ assert client.control_headers(None) == {} and client.control_headers("") == {}
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_invoke_extension_and_the_data_plane_send_the_token(monkeypatch):
129
+ seen = []
130
+
131
+ def handle(request: httpx.Request) -> httpx.Response:
132
+ seen.append((request.url.path, request.headers.get("authorization")))
133
+ return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"parts": []}})
134
+
135
+ real = httpx.AsyncClient
136
+ monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle)))
137
+ card = {"capabilities": {"extensions": [{"uri": "urn:agentenv:test-exec/v1", "params": {
138
+ "methods": {"run": {"endpoint": "/agentenv/ext/test-exec/run"}}}}]}}
139
+
140
+ await client.invoke_extension("http://desk", card, "urn:agentenv:test-exec/v1", {}, method="run",
141
+ headers=client.control_headers("own"))
142
+ await client.get_data("http://desk", headers=client.control_headers("own"))
143
+ with client.authorized({"http://desk": client.control_headers("granted")}):
144
+ await client.add_data("http://desk", [])
145
+ await client.reset_data("http://desk", headers={})
146
+
147
+ assert seen == [("/agentenv/ext/test-exec/run", "Bearer own"), ("/agentenv", "Bearer own"),
148
+ ("/agentenv", "Bearer granted"), ("/agentenv", None)]