agentenv-framework-protocol 0.1.442__tar.gz → 0.1.444__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 (117) hide show
  1. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/.gitignore +1 -0
  2. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/PKG-INFO +13 -7
  3. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/README.md +12 -6
  4. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/pyproject.toml +1 -1
  5. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/app.py +2 -1
  6. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/apps.py +32 -4
  7. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/card.py +1 -1
  8. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/checks.py +2 -0
  9. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/driver.py +4 -1
  10. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/extensions.py +6 -2
  11. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/operations.py +2 -0
  12. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/platform.py +3 -2
  13. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/website_browser.py +83 -26
  14. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_driver.py +17 -1
  15. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_website_browser.py +120 -20
  16. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/LICENSE +0 -0
  17. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/NOTICE +0 -0
  18. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/THIRD_PARTY_NOTICES.md +0 -0
  19. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/examples/__init__.py +0 -0
  20. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/examples/advanced_agent.py +0 -0
  21. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/examples/basic_agent.py +0 -0
  22. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/examples/custom_extensions_agent.py +0 -0
  23. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/examples/multimodal_agent.py +0 -0
  24. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/examples/reference_model.py +0 -0
  25. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/examples/streaming_agent.py +0 -0
  26. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/__init__.py +0 -0
  27. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  28. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  29. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  30. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  31. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  32. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  33. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
  34. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  35. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  36. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  37. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  38. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/agent_env_environment.py +0 -0
  39. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/client.py +0 -0
  40. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/__init__.py +0 -0
  41. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/_declare.py +0 -0
  42. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/_linux.py +0 -0
  43. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/_load.py +0 -0
  44. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/_with_fds.py +0 -0
  45. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/app_data.py +0 -0
  46. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/app_runtime.py +0 -0
  47. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
  48. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
  49. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
  50. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/conformance/client.py +0 -0
  51. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
  52. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
  53. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/conformance/report.py +0 -0
  54. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/dataplane.py +0 -0
  55. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/errors.py +0 -0
  56. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/frames.py +0 -0
  57. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/keys.py +0 -0
  58. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/launch.py +0 -0
  59. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/manifest.py +0 -0
  60. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/queue.py +0 -0
  61. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/recording.py +0 -0
  62. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/results.py +0 -0
  63. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/run_install_step.py +0 -0
  64. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/runtime.py +0 -0
  65. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/serve.py +0 -0
  66. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/serve_app.py +0 -0
  67. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/session.py +0 -0
  68. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/settings.py +0 -0
  69. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/skill.py +0 -0
  70. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/spaces.py +0 -0
  71. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/computer/tools.py +0 -0
  72. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/errors.py +0 -0
  73. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/manifest.py +0 -0
  74. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/preflight.py +0 -0
  75. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/tool_access.py +0 -0
  76. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/tool_client.py +0 -0
  77. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/tool_context.py +0 -0
  78. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/transfers.py +0 -0
  79. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/src/agentenv_protocol/types.py +0 -0
  80. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/computer_fake.py +0 -0
  81. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/probe_fake.py +0 -0
  82. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_a2a_agent.py +0 -0
  83. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_a2a_agent_examples.py +0 -0
  84. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_a2a_cdp_config.py +0 -0
  85. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_a2a_object_transfers.py +0 -0
  86. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_a2a_peer_agents.py +0 -0
  87. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_a2a_proxy.py +0 -0
  88. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_a2a_staging.py +0 -0
  89. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_a2a_trajectory_log.py +0 -0
  90. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_agent_env_environment.py +0 -0
  91. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_client.py +0 -0
  92. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_app_data.py +0 -0
  93. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_app_server.py +0 -0
  94. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_conformance.py +0 -0
  95. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_conformance_checks.py +0 -0
  96. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_conformance_recording.py +0 -0
  97. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_dataplane.py +0 -0
  98. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_declare.py +0 -0
  99. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_extensions.py +0 -0
  100. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_frames.py +0 -0
  101. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_queue.py +0 -0
  102. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_recording.py +0 -0
  103. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_serve.py +0 -0
  104. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_computer_spaces.py +0 -0
  105. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_control_token.py +0 -0
  106. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_data_objects.py +0 -0
  107. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_errors.py +0 -0
  108. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_extension_methods.py +0 -0
  109. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_intake.py +0 -0
  110. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_manifest.py +0 -0
  111. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_preflight.py +0 -0
  112. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_tool_access.py +0 -0
  113. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_tool_annotations.py +0 -0
  114. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_tool_client.py +0 -0
  115. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_tool_context.py +0 -0
  116. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_tool_context_fastmcp.py +0 -0
  117. {agentenv_framework_protocol-0.1.442 → agentenv_framework_protocol-0.1.444}/tests/test_tool_context_injection.py +0 -0
@@ -50,6 +50,7 @@ __pycache__/
50
50
  build/
51
51
  develop-eggs/
52
52
  dist/
53
+ node_modules/
53
54
  downloads/
54
55
  eggs/
55
56
  .eggs/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentenv-framework-protocol
3
- Version: 0.1.442
3
+ Version: 0.1.444
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
@@ -561,7 +561,8 @@ driver has them, through the `served_tools`, `served_extensions` and `served_ope
561
561
  - **The input queue.** Input calls run one at a time in arrival order; one that waits past
562
562
  `queue_wait_s` is refused `input_busy`. Screenshots and waits never queue.
563
563
  - **The computer skill.** `computer/v1` declares the seven tools, its params the driver's
564
- features. The driver serves its skill through `skill/v1` (an `@agent_skill` handler on the base
564
+ features, among them `screen`: `desktop`, or `page` for a driver whose screen is a website
565
+ browser's active tab, which a computer env pairs with exactly one website browser. The driver serves its skill through `skill/v1` (an `@agent_skill` handler on the base
565
566
  class): a SKILL.md written from the computer as it booted (its platform, session user and home,
566
567
  the live screen size, what its input takes, its OS's shortcuts) and ending with the driver
567
568
  class's `notes`, markdown for what its card can't say. A driver whose software ships a skill of
@@ -569,7 +570,9 @@ driver has them, through the `served_tools`, `served_extensions` and `served_ope
569
570
  computer skill leads with it, the computer's part following.
570
571
  - **Framework extensions.** `computer-ready/v1` (`status`: real checks of the display and a
571
572
  capture, the driver's own `ready_checks()`, and one per app on the computer, asked of the app's
572
- own server (see Computer apps); tools refuse `device_unavailable` until they pass once), `computer-display/v1`, `computer-spaces/v1` (the default space, written into the tool
573
+ own server (see Computer apps); tools refuse `device_unavailable` until they pass once; a check
574
+ that can't pass again is `final`, and while one fails the status carries `failed`, saying why,
575
+ which `boot.ready()` raises at once rather than waiting out its timeout), `computer-display/v1`, `computer-spaces/v1` (the default space, written into the tool
573
576
  descriptions), `computer-keyboard/v1`, `computer-exec/v1` (`run {argv, cwd?, env?, timeout_s?,
574
577
  request_id}`: output capped per stream, a repeated `request_id` answered with the first result) and,
575
578
  with `recording`, `computer-recording/v1` (below).
@@ -798,7 +801,7 @@ its card lists each one's card as a child environment, rooted at `/svc/<app>/`,
798
801
  requests to the app's server.
799
802
 
800
803
  `MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
801
- env stores, and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
804
+ env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
802
805
  JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed.
803
806
 
804
807
  ### Website browsers
@@ -825,8 +828,8 @@ are the profile's template. The base class adds:
825
828
  `AGENTENV_SITE_PROXY` set, it goes out through that proxy, with WebRTC's non-proxied UDP off.
826
829
  Alone it bypasses nothing, loopback included; in a computer it bypasses loopback (`127.0.0.1`,
827
830
  `[::1]`, `localhost`), so the apps beside it stay reachable while `*.localhost` names still go
828
- through. Alone (`AGENTENV_BROWSER_ALONE=1`) it also takes `--window-size` and `--headless=new`,
829
- except on the open web (`AGENTENV_BROWSER_OPEN_WEB=1`), and refuses to start with no proxy.
831
+ through. Alone, on a computer with no display (`XDG_SESSION_TYPE=none`, a browser computer's), it
832
+ also takes `--window-size` and `--headless=new`, and refuses to start with no proxy.
830
833
  `declaration()` refuses a subclass whose command carries any of these, or `--remote-allow-origins`, or
831
834
  any other way to open a debugging port.
832
835
  - **Before each launch** it empties its cache, so no launch inherits another's, and writes the deploy's
@@ -859,7 +862,10 @@ are the profile's template. The base class adds:
859
862
  launch (with its cause: `boot`, `server_restart`, `launch`, `data/add` or `data/reset`), each stop it made
860
863
  and each exit it didn't, with its exit `code` or the `signal` that ended it, since the computer booted.
861
864
  Nothing relaunches a browser that exits on its own: in a computer the agent can, and alone it's an infra
862
- failure, its ready check false.
865
+ failure, its ready check false. `page/viewport`, `page/capture` and `page/input`, framework-only, are
866
+ its active tab's page as a page driver sees it: its size, which headless is its window's; a PNG of it,
867
+ or `blank` for a tab that paints nothing within 3 s (1 s with no URL yet); and CDP input calls
868
+ (`Input.dispatchMouseEvent`, `Input.dispatchKeyEvent`, `Input.insertText`), sent to it in order.
863
869
  - **Its skill**: sites by their real names, files to upload in `~/Uploads` and downloads in
864
870
  `~/Downloads`, folders it makes.
865
871
 
@@ -505,7 +505,8 @@ driver has them, through the `served_tools`, `served_extensions` and `served_ope
505
505
  - **The input queue.** Input calls run one at a time in arrival order; one that waits past
506
506
  `queue_wait_s` is refused `input_busy`. Screenshots and waits never queue.
507
507
  - **The computer skill.** `computer/v1` declares the seven tools, its params the driver's
508
- features. The driver serves its skill through `skill/v1` (an `@agent_skill` handler on the base
508
+ features, among them `screen`: `desktop`, or `page` for a driver whose screen is a website
509
+ browser's active tab, which a computer env pairs with exactly one website browser. The driver serves its skill through `skill/v1` (an `@agent_skill` handler on the base
509
510
  class): a SKILL.md written from the computer as it booted (its platform, session user and home,
510
511
  the live screen size, what its input takes, its OS's shortcuts) and ending with the driver
511
512
  class's `notes`, markdown for what its card can't say. A driver whose software ships a skill of
@@ -513,7 +514,9 @@ driver has them, through the `served_tools`, `served_extensions` and `served_ope
513
514
  computer skill leads with it, the computer's part following.
514
515
  - **Framework extensions.** `computer-ready/v1` (`status`: real checks of the display and a
515
516
  capture, the driver's own `ready_checks()`, and one per app on the computer, asked of the app's
516
- own server (see Computer apps); tools refuse `device_unavailable` until they pass once), `computer-display/v1`, `computer-spaces/v1` (the default space, written into the tool
517
+ own server (see Computer apps); tools refuse `device_unavailable` until they pass once; a check
518
+ that can't pass again is `final`, and while one fails the status carries `failed`, saying why,
519
+ which `boot.ready()` raises at once rather than waiting out its timeout), `computer-display/v1`, `computer-spaces/v1` (the default space, written into the tool
517
520
  descriptions), `computer-keyboard/v1`, `computer-exec/v1` (`run {argv, cwd?, env?, timeout_s?,
518
521
  request_id}`: output capped per stream, a repeated `request_id` answered with the first result) and,
519
522
  with `recording`, `computer-recording/v1` (below).
@@ -742,7 +745,7 @@ its card lists each one's card as a child environment, rooted at `/svc/<app>/`,
742
745
  requests to the app's server.
743
746
 
744
747
  `MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
745
- env stores, and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
748
+ env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
746
749
  JSON, or a driver's `DriverDeclaration`, without the class's own dependencies installed.
747
750
 
748
751
  ### Website browsers
@@ -769,8 +772,8 @@ are the profile's template. The base class adds:
769
772
  `AGENTENV_SITE_PROXY` set, it goes out through that proxy, with WebRTC's non-proxied UDP off.
770
773
  Alone it bypasses nothing, loopback included; in a computer it bypasses loopback (`127.0.0.1`,
771
774
  `[::1]`, `localhost`), so the apps beside it stay reachable while `*.localhost` names still go
772
- through. Alone (`AGENTENV_BROWSER_ALONE=1`) it also takes `--window-size` and `--headless=new`,
773
- except on the open web (`AGENTENV_BROWSER_OPEN_WEB=1`), and refuses to start with no proxy.
775
+ through. Alone, on a computer with no display (`XDG_SESSION_TYPE=none`, a browser computer's), it
776
+ also takes `--window-size` and `--headless=new`, and refuses to start with no proxy.
774
777
  `declaration()` refuses a subclass whose command carries any of these, or `--remote-allow-origins`, or
775
778
  any other way to open a debugging port.
776
779
  - **Before each launch** it empties its cache, so no launch inherits another's, and writes the deploy's
@@ -803,7 +806,10 @@ are the profile's template. The base class adds:
803
806
  launch (with its cause: `boot`, `server_restart`, `launch`, `data/add` or `data/reset`), each stop it made
804
807
  and each exit it didn't, with its exit `code` or the `signal` that ended it, since the computer booted.
805
808
  Nothing relaunches a browser that exits on its own: in a computer the agent can, and alone it's an infra
806
- failure, its ready check false.
809
+ failure, its ready check false. `page/viewport`, `page/capture` and `page/input`, framework-only, are
810
+ its active tab's page as a page driver sees it: its size, which headless is its window's; a PNG of it,
811
+ or `blank` for a tab that paints nothing within 3 s (1 s with no URL yet); and CDP input calls
812
+ (`Input.dispatchMouseEvent`, `Input.dispatchKeyEvent`, `Input.insertText`), sent to it in order.
807
813
  - **Its skill**: sites by their real names, files to upload in `~/Uploads` and downloads in
808
814
  `~/Downloads`, folders it makes.
809
815
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.442"
3
+ version = "0.1.444"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -236,7 +236,7 @@ class AppDeclaration(BaseModel):
236
236
  """What a ComputerAppEnvironment class declares, as a ``computer_app`` env stores it: the platforms it ``supports``,
237
237
  as a driver names them; ``os``, what it declares for each OS type; ``settings`` and ``build_settings``, the JSON
238
238
  schemas of its ``Settings`` and of its ``BuildSettings`` when it declares any; its ``skill``; and the ``data``
239
- methods, ``install_steps`` and ``ready_checks`` it serves, by name."""
239
+ methods, ``install_steps`` and ``ready_checks`` it serves, by name; and ``browser``, true for a website browser."""
240
240
 
241
241
  model_config = ConfigDict(extra="forbid")
242
242
 
@@ -249,6 +249,7 @@ class AppDeclaration(BaseModel):
249
249
  data: list[str]
250
250
  install_steps: list[InstallStep]
251
251
  ready_checks: list[AppReadyCheck]
252
+ browser: Optional[bool] = None
252
253
 
253
254
  @model_validator(mode="before")
254
255
  @classmethod
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import copy
11
+ import json
11
12
  from pathlib import Path
12
13
  from typing import Optional
13
14
 
@@ -16,7 +17,7 @@ import httpx
16
17
  from .. import client
17
18
  from ..types import RPC_PATH, WELL_KNOWN_PATH, EnvironmentCard
18
19
  from .app import COMPUTER_APP, FAILURE, SOCKET
19
- from .app_runtime import CHECK_TIMEOUT_S, AppReadiness
20
+ from .app_runtime import CHECK_TIMEOUT_S, LAUNCHES, AppReadiness
20
21
  from .operations import ReadyCheck
21
22
 
22
23
  #: Where a composed computer installs its apps, a folder each.
@@ -54,7 +55,7 @@ async def check(folder: Path) -> ReadyCheck:
54
55
  async def children(folders: list[Path]) -> list[EnvironmentCard]:
55
56
  """Each app in ``folders`` as a child environment of its computer: its server's card, rooted at ``/svc/<name>/``,
56
57
  or, while its server isn't answering, its name and route alone."""
57
- cards = await asyncio.gather(*(_card(folder) for folder in folders))
58
+ cards = await asyncio.gather(*(served_card(folder) for folder in folders))
58
59
  return [EnvironmentCard.model_validate(child_card(folder.name, card)) if card is not None
59
60
  else EnvironmentCard(name=folder.name, url=f"/svc/{folder.name}{RPC_PATH}")
60
61
  for folder, card in zip(folders, cards)]
@@ -85,7 +86,7 @@ def child_card(name: str, card: dict) -> dict:
85
86
 
86
87
  async def owned_paths(folders: list[Path]) -> list[str]:
87
88
  """The paths under the session user's home that the apps in ``folders`` keep their data in, as their cards say."""
88
- cards = await asyncio.gather(*(_card(folder) for folder in folders))
89
+ cards = await asyncio.gather(*(served_card(folder) for folder in folders))
89
90
  return [path for card in cards if card is not None
90
91
  for path in client.extension_params(card, COMPUTER_APP).get("paths") or []]
91
92
 
@@ -104,7 +105,7 @@ def endpoint(card: dict, method: str) -> str:
104
105
  return client.find_extension_method(card, COMPUTER_APP, method)["endpoint"]
105
106
 
106
107
 
107
- async def _card(folder: Path) -> Optional[dict]:
108
+ async def served_card(folder: Path) -> Optional[dict]:
108
109
  """The card the app in ``folder`` serves, or None while its server isn't answering."""
109
110
  try:
110
111
  async with _client(folder / SOCKET) as http:
@@ -113,6 +114,33 @@ async def _card(folder: Path) -> Optional[dict]:
113
114
  return None
114
115
 
115
116
 
117
+ def ended_on_its_own(folder: Path) -> Optional[str]:
118
+ """Why the app in ``folder`` can't be ready again where nothing launches it but its server: its launch log says it
119
+ ended on its own, an infrastructure failure; None while it hasn't."""
120
+ for entry in launch_log(folder):
121
+ if entry.get("event") == "exit":
122
+ how = (f"by signal {entry['signal']}" if "signal" in entry else
123
+ f"with code {entry['code']}" if "code" in entry else "in a way that wasn't recorded")
124
+ return (f"it ended on its own at {entry.get('at')}, {how}, and nothing launches it again: an "
125
+ "infrastructure failure")
126
+ return None
127
+
128
+
129
+ def launch_log(folder: Path) -> list[dict]:
130
+ """Each entry of the launch log of the app in ``folder`` that's whole, in order."""
131
+ try:
132
+ lines = (folder / LAUNCHES).read_text(encoding="utf-8").splitlines()
133
+ except FileNotFoundError:
134
+ return []
135
+ entries = []
136
+ for line in lines:
137
+ try:
138
+ entries.append(json.loads(line))
139
+ except ValueError:
140
+ continue
141
+ return entries
142
+
143
+
116
144
  def _unanswered(folder: Path) -> str:
117
145
  try:
118
146
  return f"its server failed to start: {(folder / FAILURE).read_text()}"
@@ -89,7 +89,7 @@ def computer_features(driver: ComputerDriverEnvironment) -> dict:
89
89
  "max_wait_ms": driver.max_wait_ms, "max_drag_points": driver.max_drag_points,
90
90
  "max_duration_ms": driver.max_duration_ms, "max_key_repeat": driver.max_key_repeat,
91
91
  "max_scroll_amount": driver.max_scroll_amount, "delivery": "foreground", "platform": driver.platform.as_dict(),
92
- "supports": list(driver.supports), "default_data_paths": list(DEFAULT_DATA_PATHS),
92
+ "supports": list(driver.supports), "default_data_paths": list(DEFAULT_DATA_PATHS), "screen": driver.screen_kind,
93
93
  }
94
94
 
95
95
 
@@ -37,6 +37,8 @@ def check_features(cls: type) -> None:
37
37
  problems.append("keys are W3C key values in their W3C case")
38
38
  except AgentEnvError as e:
39
39
  problems.append(f"keys: {e.message}")
40
+ if cls.screen_kind not in ("desktop", "page"):
41
+ problems.append(f"screen_kind is desktop or page, got {cls.screen_kind!r}")
40
42
  if ("set_display" in declared(cls)) != bool(cls.display_modes):
41
43
  problems.append("display_modes and @set_display come together")
42
44
  problems += check_recording(cls.recording)
@@ -4,7 +4,7 @@ computer's apps as child environments, each reached at ``/svc/<name>/`` through
4
4
  from __future__ import annotations
5
5
 
6
6
  import asyncio
7
- from typing import Any, Callable, ClassVar
7
+ from typing import Any, Callable, ClassVar, Literal
8
8
 
9
9
  import httpx
10
10
  from pydantic import BaseModel, ConfigDict
@@ -79,6 +79,9 @@ class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironmen
79
79
  recording: ClassVar[Recording | None] = Recording()
80
80
  #: What an agent should know about this driver's computer beyond what its card says, as markdown: the end of its skill.
81
81
  notes: ClassVar[str] = ""
82
+ #: What the computer's screen shows, its card's ``screen``: a ``desktop``, or a website browser's ``page``, its
83
+ #: active tab.
84
+ screen_kind: ClassVar[Literal["desktop", "page"]] = "desktop"
82
85
  tool_access = True
83
86
  #: What the base class keeps for the driver, built by ``__init__``.
84
87
  _computer: ComputerRuntime | None = None
@@ -45,8 +45,12 @@ class Extensions:
45
45
  description="Whether the computer is usable now, by real checks; never sends input.")
46
46
  async def _computer_ready_status(self) -> dict:
47
47
  checks = await self._computer.ready_checks()
48
- return {"ready": all(c.ok for c in checks),
49
- "checks": [{"name": c.name, "ok": c.ok, "detail": c.detail} for c in checks]}
48
+ status = {"ready": all(c.ok for c in checks),
49
+ "checks": [{"name": c.name, "ok": c.ok, "detail": c.detail, **({"final": True} if c.final else {})}
50
+ for c in checks]}
51
+ if failed := [c for c in checks if c.final and not c.ok]:
52
+ status["failed"] = "; ".join(f"{c.name}: {c.detail}" for c in failed)
53
+ return status
50
54
 
51
55
  @extension(COMPUTER_DISPLAY, op="get", method="GET", description="Get or set the display's resolution and scale.")
52
56
  async def _computer_display_get(self) -> dict:
@@ -74,6 +74,8 @@ class ReadyCheck:
74
74
  name: str
75
75
  ok: bool
76
76
  detail: str = ""
77
+ #: Failed for good: the check can't pass again, so the computer has failed.
78
+ final: bool = False
77
79
 
78
80
 
79
81
  @dataclass(frozen=True)
@@ -28,14 +28,15 @@ class Platform:
28
28
 
29
29
  def detect(environ: dict | None = None, os_release: Path = Path("/etc/os-release")) -> Platform:
30
30
  """The running platform: the OS, ``/etc/os-release``'s ID and VERSION_ID on Linux, the machine, and the
31
- display from ``XDG_SESSION_TYPE``, else ``DISPLAY`` (x11) or ``WAYLAND_DISPLAY`` (wayland)."""
31
+ display from ``XDG_SESSION_TYPE``, ``none`` for a computer with no display, else ``DISPLAY`` (x11) or
32
+ ``WAYLAND_DISPLAY`` (wayland)."""
32
33
  environ = os.environ if environ is None else environ
33
34
  name = _OS.get(sys.platform, sys.platform)
34
35
  release = _os_release(os_release) if name == "linux" else {}
35
36
  display = None
36
37
  if name == "linux":
37
38
  session = (environ.get("XDG_SESSION_TYPE") or "").strip().lower()
38
- if session in ("x11", "wayland"):
39
+ if session in ("x11", "wayland", "none"):
39
40
  display = session
40
41
  elif environ.get("DISPLAY"):
41
42
  display = "x11"
@@ -2,8 +2,9 @@
2
2
  its build installs and starts; this class adds what agentenv owns, the same in both: its flags after the subclass's
3
3
  command, the unattended-use policies, a CDP connection over the browser's debugging pipe, the settings every browser
4
4
  takes, a fresh cache and the deploy's prompt answers at every launch, another window for a launch while it runs, its
5
- skill and ``browser/v1`` on its card. Whether it runs alone, on the open web and through which site proxy come from
6
- variables agentenv sets, never from its settings."""
5
+ skill and ``browser/v1`` on its card. It runs alone, headless, on a computer with no display, a browser computer's,
6
+ whose screen is its page; which site proxy it goes through comes from a variable agentenv sets, never from its
7
+ settings."""
7
8
  from __future__ import annotations
8
9
 
9
10
  import asyncio
@@ -23,11 +24,11 @@ from pydantic import BaseModel, Field, field_validator
23
24
 
24
25
  from .agent_env_environment import agent_skill, extension
25
26
  from .computer import AppDeclaration, ComputerAppEnvironment, Launch, install_step, ready_check
27
+ from .computer import app as computer_app
28
+ from .computer.errors import DeviceUnavailable, InvalidArgument
26
29
 
27
30
  logger = logging.getLogger(__name__)
28
31
 
29
- ALONE_ENV = "AGENTENV_BROWSER_ALONE"
30
- OPEN_WEB_ENV = "AGENTENV_BROWSER_OPEN_WEB"
31
32
  SITE_PROXY_ENV = "AGENTENV_SITE_PROXY"
32
33
  BROWSER = "urn:agentenv:browser/v1"
33
34
  #: The flags agentenv sets, by prefix, which a subclass's command may not carry: among them every way to open a debugging
@@ -51,6 +52,9 @@ HASH_CHUNK_BYTES = 1 << 20
51
52
  AGENT = "agent"
52
53
  #: The targets agentenv's connection follows.
53
54
  PAGES = [{"type": "page"}]
55
+ #: The CDP input a computer's page driver may send to the active tab, and how long its screenshot may take.
56
+ PAGE_INPUT = frozenset({"Input.dispatchMouseEvent", "Input.dispatchKeyEvent", "Input.insertText"})
57
+ CAPTURE_TIMEOUT_S, UNNAVIGATED_CAPTURE_TIMEOUT_S = 3.0, 1.0
54
58
  #: Alone, loopback goes through the proxy too. In a computer, the apps on loopback beside it stay reachable; ``<-loopback>``
55
59
  #: removes Chromium's own rule, so ``*.localhost`` names still don't.
56
60
  ALONE_BYPASS = "<-loopback>"
@@ -187,8 +191,8 @@ class BrowserControl:
187
191
  ``dialogs`` says to: always leaving a page that asks before it's left, so no answer of agentenv's blocks a
188
192
  navigation."""
189
193
 
190
- def __init__(self, devtools: DevToolsPipe, dialogs: str) -> None:
191
- self.devtools, self.dialogs = devtools, dialogs
194
+ def __init__(self, devtools: DevToolsPipe, dialogs: str, viewport: Optional[tuple[int, int]] = None) -> None:
195
+ self.devtools, self.dialogs, self.viewport = devtools, dialogs, viewport
192
196
  #: When this record starts: a server that restarts starts another.
193
197
  self.since = _now()
194
198
  self.tabs: dict[str, dict] = {}
@@ -209,7 +213,8 @@ class BrowserControl:
209
213
  downloads unattended: into ``~/Downloads``, a name downloaded again kept beside the first."""
210
214
  self.ended(STOP)
211
215
  await self.devtools.call("Target.setDiscoverTargets", {"discover": True, "filter": PAGES})
212
- await self.devtools.call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": False,
216
+ await self.devtools.call("Target.setAutoAttach", {"autoAttach": True,
217
+ "waitForDebuggerOnStart": self.viewport is not None,
213
218
  "flatten": True, "filter": PAGES})
214
219
  await self.devtools.call("Browser.setDownloadBehavior", {"behavior": "default", "eventsEnabled": True})
215
220
 
@@ -268,6 +273,36 @@ class BrowserControl:
268
273
  "closed_tabs": list(self.closed_tabs), "downloads": list(self.downloads.values()),
269
274
  "dialogs": list(self.dialog_log)}
270
275
 
276
+ async def active_session(self) -> str:
277
+ """The session of the active tab, else of the first open one; DeviceUnavailable with no tab open."""
278
+ active = await self.active_tab() or next(iter(self.tabs), None)
279
+ session = next((session for session, target in self._sessions.items() if target == active), None)
280
+ if session is None:
281
+ raise DeviceUnavailable("the browser has no tab open")
282
+ return session
283
+
284
+ async def capture(self) -> Optional[str]:
285
+ """The active tab's page as it shows now, a PNG, base64; None for a tab that paints nothing within
286
+ ``CAPTURE_TIMEOUT_S``, which a desktop browser shows blank, or sooner for a tab with no URL yet, such as a new
287
+ tab whose navigation the browser blocked, which may never paint."""
288
+ session = await self.active_session()
289
+ navigated = self.tabs.get(self._sessions[session], {}).get("url")
290
+ try:
291
+ shot = await self.devtools.call("Page.captureScreenshot", {"format": "png", "optimizeForSpeed": True},
292
+ session_id=session,
293
+ timeout_s=CAPTURE_TIMEOUT_S if navigated else UNNAVIGATED_CAPTURE_TIMEOUT_S)
294
+ except asyncio.TimeoutError:
295
+ return None
296
+ return shot["data"]
297
+
298
+ async def dispatch(self, events: list[dict]) -> None:
299
+ """Each of ``events``, a CDP input call, sent to the active tab in order; InvalidArgument for any other call."""
300
+ if refused := [event.get("method") for event in events if event.get("method") not in PAGE_INPUT]:
301
+ raise InvalidArgument(f"page input takes {', '.join(sorted(PAGE_INPUT))}, not {refused}")
302
+ session = await self.active_session()
303
+ for event in events:
304
+ await self.devtools.call(event["method"], event.get("params") or {}, session_id=session)
305
+
271
306
  async def _refresh(self, target: Optional[str] = None) -> None:
272
307
  """The URL and title of the tab ``target``, or of every open tab, as the browser has them now: it sends no
273
308
  event when a page's title changes."""
@@ -307,9 +342,19 @@ class BrowserControl:
307
342
  def _on_Target_attachedToTarget(self, params: dict, session: Optional[str]) -> None:
308
343
  if params["targetInfo"].get("type") == "page":
309
344
  self._sessions[params["sessionId"]] = params["targetInfo"]["targetId"]
310
- self._spawn(self.devtools.call("Page.enable", session_id=params["sessionId"]))
311
- # A page attached as it opens, a popup say, waits for this before it navigates.
312
- self._spawn(self.devtools.call("Runtime.runIfWaitingForDebugger", session_id=params["sessionId"]))
345
+ self._spawn(self._let_run(params["sessionId"]))
346
+
347
+ async def _let_run(self, session: str) -> None:
348
+ """A page it attached to, enabled and, headless, held to its window's size, then told to run: a page attached as
349
+ it opens, a popup say, or any new page headless, waits for that before it runs."""
350
+ try:
351
+ await self.devtools.call("Page.enable", session_id=session)
352
+ if self.viewport is not None:
353
+ width, height = self.viewport
354
+ await self.devtools.call("Emulation.setDeviceMetricsOverride", {
355
+ "width": width, "height": height, "deviceScaleFactor": 1, "mobile": False}, session_id=session)
356
+ finally:
357
+ await self.devtools.call("Runtime.runIfWaitingForDebugger", session_id=session)
313
358
 
314
359
  def _on_Target_detachedFromTarget(self, params: dict, session: Optional[str]) -> None:
315
360
  self._sessions.pop(params["sessionId"], None)
@@ -433,16 +478,19 @@ class WebsiteBrowserEnvironment(ComputerAppEnvironment):
433
478
  for folder in (UPLOADS, DOWNLOADS):
434
479
  (Path.home() / folder).mkdir(exist_ok=True)
435
480
  #: agentenv's control of it over that connection, and the record it keeps.
436
- self.control = BrowserControl(self.devtools, self.settings.dialogs)
481
+ self.control = BrowserControl(self.devtools, self.settings.dialogs,
482
+ (self.settings.window.width, self.settings.window.height) if _alone()
483
+ else None)
437
484
  self.devtools.on_close(self._pipe_closed)
438
485
 
439
486
  @classmethod
440
487
  def declaration(cls) -> AppDeclaration:
441
- """What the class declares; ValueError for a launch whose command carries a flag agentenv owns."""
488
+ """What the class declares, a website browser; ValueError for a launch whose command carries a flag agentenv
489
+ owns."""
442
490
  for declared in cls._installs.values():
443
491
  if declared.launch is not None:
444
492
  _refuse_owned(cls.__name__, declared.launch)
445
- return super().declaration()
493
+ return super().declaration().model_copy(update={"browser": True})
446
494
 
447
495
  def profile_dir(self) -> Path:
448
496
  return Path.home() / self.profile
@@ -469,7 +517,7 @@ class WebsiteBrowserEnvironment(ComputerAppEnvironment):
469
517
  "names none")
470
518
  if alone:
471
519
  flags.append(f"--window-size={settings.window.width},{settings.window.height}")
472
- if _headless():
520
+ if alone:
473
521
  flags.append("--headless=new")
474
522
  return flags
475
523
 
@@ -491,7 +539,7 @@ class WebsiteBrowserEnvironment(ComputerAppEnvironment):
491
539
 
492
540
  def _command_line_tabs(self) -> list[str]:
493
541
  """The start tabs its command line opens: headless Chromium takes one, and the rest open over its pipe."""
494
- return self.settings.start_urls[:1] if _headless() else self.settings.start_urls
542
+ return self.settings.start_urls[:1] if _alone() else self.settings.start_urls
495
543
 
496
544
  async def _launch_fds(self) -> dict[int, int]:
497
545
  """Before each launch, an empty cache and the deploy's prompt answers in its profile; and a new debugging pipe:
@@ -568,10 +616,27 @@ class WebsiteBrowserEnvironment(ComputerAppEnvironment):
568
616
  "A website browser agentenv runs: its build, the clock its pages see and, alone, its window. record returns "
569
617
  "what agentenv's own connection to it saw: its open tabs, each with whether it's active, the tabs closed and "
570
618
  "who closed them, its downloads with their size and sha256, its JavaScript dialogs and who answered each, and "
571
- "each launch, stop and exit of it since the computer booted, with its cause."))
619
+ "each launch, stop and exit of it since the computer booted, with its cause. page/viewport, page/capture and "
620
+ "page/input are its active tab's page as a computer's page driver sees it: its size in pixels, which headless "
621
+ "is its window's, a PNG of it, and CDP input calls sent to it in order."))
572
622
  async def browser_record(self) -> dict:
573
623
  return {**await self.control.record(), "launches": self._computer_app.launches()}
574
624
 
625
+ @extension(BROWSER, op="page/viewport", method="GET")
626
+ async def browser_page_viewport(self) -> dict:
627
+ window = self.settings.window
628
+ return {"width": window.width, "height": window.height}
629
+
630
+ @extension(BROWSER, op="page/capture", method="GET")
631
+ async def browser_page_capture(self) -> dict:
632
+ png = await self.control.capture()
633
+ return {"png": png} if png is not None else {"blank": True}
634
+
635
+ @extension(BROWSER, op="page/input")
636
+ async def browser_page_input(self, events: list[dict]) -> dict:
637
+ await self.control.dispatch(events)
638
+ return {}
639
+
575
640
  def extension_params(self, uri: str) -> dict:
576
641
  if uri == BROWSER:
577
642
  params: dict[str, Any] = {"chromium": self.version, "clock": "real"}
@@ -586,11 +651,8 @@ class WebsiteBrowserEnvironment(ComputerAppEnvironment):
586
651
 
587
652
 
588
653
  def _alone() -> bool:
589
- return os.environ.get(ALONE_ENV) == "1"
590
-
591
-
592
- def _open_web() -> bool:
593
- return os.environ.get(OPEN_WEB_ENV) == "1"
654
+ """Whether it runs on a computer with no display, a browser computer's, alone and headless."""
655
+ return computer_app.detect().display == "none"
594
656
 
595
657
 
596
658
  def _shm_bytes(path: str = "/dev/shm") -> int:
@@ -601,11 +663,6 @@ def _shm_bytes(path: str = "/dev/shm") -> int:
601
663
  return stats.f_frsize * stats.f_blocks
602
664
 
603
665
 
604
- def _headless() -> bool:
605
- """Alone it runs headless, but headful on Xvfb for the open web, whose sites block headless browsers."""
606
- return _alone() and not _open_web()
607
-
608
-
609
666
  def _refuse_owned(name: str, launch: Launch) -> None:
610
667
  if owned := [arg for arg in launch.command if arg.startswith(OWNED_FLAGS)]:
611
668
  raise ValueError(f"{name}'s launch sets {', '.join(owned)}, which agentenv sets for every website browser")
@@ -115,6 +115,7 @@ def test_unknown_predicates_are_refused():
115
115
  ({"keys": ("enter",)}, "keys are W3C key values in their W3C case"),
116
116
  ({"keys": ("Return",)}, "keys: 'Return' is not a W3C key value"),
117
117
  ({"display_modes": ((10, 10),)}, "display_modes and @set_display come together"),
118
+ ({"screen_kind": "tablet"}, "screen_kind is desktop or page, got 'tablet'"),
118
119
  ])
119
120
  def test_features_are_checked_when_the_class_is_defined(features, error):
120
121
  with pytest.raises(TypeError, match=error):
@@ -169,7 +170,7 @@ async def test_the_card_declares_the_contract_the_driver_implements():
169
170
  computer = extensions["urn:agentenv:computer/v1"]["params"]
170
171
  assert computer["buttons"] == ["left", "right", "middle"] and computer["max_click_count"] == 3
171
172
  assert computer["keys"] == "all" and computer["platform"] == LINUX.as_dict() and computer["delivery"] == "foreground"
172
- assert computer["default_data_paths"] == ["Desktop", "Documents", "Downloads"]
173
+ assert computer["default_data_paths"] == ["Desktop", "Documents", "Downloads"] and computer["screen"] == "desktop"
173
174
  assert "methods" not in computer and extensions["urn:agentenv:skill/v1"]["params"]["methods"]["get"]["method"] == "GET"
174
175
  assert extensions["urn:agentenv:computer-pointer/v1"]["params"]["buttons"] == ["left", "right", "middle"]
175
176
  assert set(extensions["urn:agentenv:computer-keyboard/v1"]["params"]["methods"]) == {"get", "set"}
@@ -294,6 +295,21 @@ async def test_the_drivers_own_ready_checks_count():
294
295
  async with http_client(Raising()) as http:
295
296
  raised = (await http.get("/agentenv/ext/computer-ready/status")).json()
296
297
  assert raised["checks"][-1] == {"name": "driver", "ok": False, "detail": "RuntimeError: probe crashed"}
298
+ assert "failed" not in before and "failed" not in raised
299
+
300
+
301
+ @pytest.mark.asyncio
302
+ async def test_a_final_check_that_fails_has_the_status_say_the_computer_failed_and_why():
303
+ class Ended(FakeDriver):
304
+ async def ready_checks(self):
305
+ return [ReadyCheck("browser", False, "it ended on its own", final=True),
306
+ ReadyCheck("session", True, "logged in", final=True)]
307
+
308
+ async with http_client(Ended()) as http:
309
+ status = (await http.get("/agentenv/ext/computer-ready/status")).json()
310
+ assert status["ready"] is False and status["failed"] == "browser: it ended on its own"
311
+ assert status["checks"][-2:] == [{"name": "browser", "ok": False, "detail": "it ended on its own", "final": True},
312
+ {"name": "session", "ok": True, "detail": "logged in", "final": True}]
297
313
 
298
314
 
299
315
  @pytest.mark.asyncio
@@ -19,10 +19,10 @@ import pytest
19
19
  from agentenv_protocol.computer import APP_SETTINGS_ENV, ComputerAppEnvironment, Launch, Ready, app, linux, session
20
20
  from agentenv_protocol import website_browser
21
21
  from agentenv_protocol.computer import app_runtime
22
+ from agentenv_protocol.computer.errors import DeviceUnavailable, InvalidArgument
22
23
  from agentenv_protocol.computer.platform import Platform
23
- from agentenv_protocol.website_browser import (ALONE_BYPASS, ALONE_ENV, BROWSER, COMPUTER_BYPASS, OPEN_WEB_ENV,
24
- POLICIES, SITE_PROXY_ENV, SKILL, BrowserControl, DevToolsError,
25
- WebsiteBrowserEnvironment)
24
+ from agentenv_protocol.website_browser import (ALONE_BYPASS, BROWSER, COMPUTER_BYPASS, POLICIES, SITE_PROXY_ENV, SKILL,
25
+ BrowserControl, DevToolsError, WebsiteBrowserEnvironment)
26
26
 
27
27
  PROXY = "http://127.0.0.1:3128"
28
28
  OWNED = ["--user-data-dir", "--disk-cache-dir", "--remote-debugging-pipe", "--no-first-run",
@@ -80,7 +80,7 @@ def browser_home(monkeypatch, tmp_path):
80
80
  monkeypatch.setenv("HOME", str(tmp_path / "home"))
81
81
  (tmp_path / "home").mkdir()
82
82
  monkeypatch.setenv("ENVIRONMENT_NAME", "chromium")
83
- for name in (ALONE_ENV, OPEN_WEB_ENV, SITE_PROXY_ENV, APP_SETTINGS_ENV):
83
+ for name in (SITE_PROXY_ENV, APP_SETTINGS_ENV):
84
84
  monkeypatch.delenv(name, raising=False)
85
85
  return tmp_path / "home"
86
86
 
@@ -89,6 +89,11 @@ def _settings(monkeypatch, **values) -> None:
89
89
  monkeypatch.setenv(APP_SETTINGS_ENV, json.dumps({"chromium": values}))
90
90
 
91
91
 
92
+ def _alone(monkeypatch) -> None:
93
+ """On a computer with no display, a browser computer's."""
94
+ monkeypatch.setattr(app, "detect", lambda: Platform(os="linux", display="none"))
95
+
96
+
92
97
  def test_in_a_computer_its_flags_follow_its_command_then_its_start_tabs_and_its_window_is_waited_for(browser_home):
93
98
  launch = Browser().launch
94
99
 
@@ -110,7 +115,7 @@ def test_in_a_computer_with_a_site_proxy_only_loopback_bypasses_it(monkeypatch):
110
115
 
111
116
 
112
117
  def test_alone_it_goes_through_its_proxy_headless_at_its_window_and_its_launch_waits_on_no_window(monkeypatch):
113
- monkeypatch.setenv(ALONE_ENV, "1")
118
+ _alone(monkeypatch)
114
119
  monkeypatch.setenv(SITE_PROXY_ENV, PROXY)
115
120
  _settings(monkeypatch, window={"width": 1024, "height": 768})
116
121
 
@@ -130,7 +135,7 @@ def test_alone_its_launch_keeps_the_port_and_command_it_waits_on_and_their_timeo
130
135
  class Waiting(WebsiteBrowserEnvironment):
131
136
  pass
132
137
 
133
- monkeypatch.setenv(ALONE_ENV, "1")
138
+ _alone(monkeypatch)
134
139
  monkeypatch.setenv(SITE_PROXY_ENV, PROXY)
135
140
 
136
141
  waiting = Waiting()
@@ -140,18 +145,8 @@ def test_alone_its_launch_keeps_the_port_and_command_it_waits_on_and_their_timeo
140
145
  assert waiting._computer_app.timeout_s == 45
141
146
 
142
147
 
143
- def test_alone_on_the_open_web_it_runs_headful(monkeypatch):
144
- monkeypatch.setenv(ALONE_ENV, "1")
145
- monkeypatch.setenv(OPEN_WEB_ENV, "1")
146
- monkeypatch.setenv(SITE_PROXY_ENV, PROXY)
147
-
148
- command = Browser().launch.command
149
-
150
- assert "--window-size=1280,800" in command and not any(flag.startswith("--headless") for flag in command)
151
-
152
-
153
148
  def test_alone_it_refuses_to_launch_without_a_site_proxy(monkeypatch):
154
- monkeypatch.setenv(ALONE_ENV, "1")
149
+ _alone(monkeypatch)
155
150
 
156
151
  with pytest.raises(ValueError, match=SITE_PROXY_ENV):
157
152
  Browser()
@@ -215,7 +210,7 @@ def test_its_settings_give_its_start_tabs_language_and_time_zone_and_no_start_ur
215
210
  @pytest.mark.asyncio
216
211
  async def test_alone_its_server_launches_it_at_once_in_its_environment_with_no_display_wait_or_launcher(
217
212
  monkeypatch, tmp_path):
218
- monkeypatch.setenv(ALONE_ENV, "1")
213
+ _alone(monkeypatch)
219
214
  monkeypatch.setenv(SITE_PROXY_ENV, PROXY)
220
215
  monkeypatch.delenv("DISPLAY", raising=False)
221
216
  monkeypatch.chdir(tmp_path)
@@ -314,7 +309,7 @@ async def test_a_launch_while_it_runs_opens_a_window_over_its_pipe_and_starts_no
314
309
  async def test_headless_it_opens_one_start_tab_from_its_command_line_and_the_rest_over_its_pipe(monkeypatch, tmp_path):
315
310
  monkeypatch.chdir(tmp_path)
316
311
  monkeypatch.setattr(app_runtime, "LAUNCHED", tmp_path / "launched")
317
- monkeypatch.setenv(ALONE_ENV, "1")
312
+ _alone(monkeypatch)
318
313
  monkeypatch.setenv(SITE_PROXY_ENV, PROXY)
319
314
  _settings(monkeypatch, start_urls=["https://slack.com", "https://linear.app", "https://github.com"])
320
315
  browser = FakeBrowser()
@@ -400,7 +395,7 @@ def test_its_policies_are_agentenvs_with_its_own_over_them_where_its_build_reads
400
395
 
401
396
  def test_its_card_declares_browser_v1_with_its_build_and_clock_and_alone_its_window(monkeypatch):
402
397
  in_computer = Browser().extension_params(BROWSER)
403
- monkeypatch.setenv(ALONE_ENV, "1")
398
+ _alone(monkeypatch)
404
399
  monkeypatch.setenv(SITE_PROXY_ENV, PROXY)
405
400
  alone = Browser().extension_params(BROWSER)
406
401
 
@@ -503,6 +498,111 @@ async def test_each_page_it_attaches_to_is_told_to_run_so_a_popup_it_attached_to
503
498
 
504
499
  assert ("Page.enable", {}, "s-popup") in devtools.calls
505
500
  assert ("Runtime.runIfWaitingForDebugger", {}, "s-popup") in devtools.calls
501
+ assert not any(method == "Emulation.setDeviceMetricsOverride" for method, _, _ in devtools.calls)
502
+ assert next(params for method, params, _ in devtools.calls if method == "Target.setAutoAttach")[
503
+ "waitForDebuggerOnStart"] is False
504
+
505
+
506
+ @pytest.mark.asyncio
507
+ async def test_alone_each_page_it_attaches_to_is_held_to_its_window_before_it_runs(tmp_path):
508
+ devtools = StubDevTools()
509
+ control = BrowserControl(devtools, "agent", (1024, 768))
510
+ await control.attach()
511
+ devtools.send("Target.attachedToTarget", {"sessionId": "s-popup", "waitingForDebugger": True, **_page("popup")})
512
+ await asyncio.sleep(0)
513
+
514
+ sent = [method for method, _, session in devtools.calls if session == "s-popup"]
515
+ assert next(params for method, params, _ in devtools.calls if method == "Target.setAutoAttach")[
516
+ "waitForDebuggerOnStart"] is True
517
+ assert sent == ["Page.enable", "Emulation.setDeviceMetricsOverride", "Runtime.runIfWaitingForDebugger"]
518
+ assert ("Emulation.setDeviceMetricsOverride", {"width": 1024, "height": 768, "deviceScaleFactor": 1,
519
+ "mobile": False}, "s-popup") in devtools.calls
520
+
521
+
522
+ @pytest.mark.asyncio
523
+ async def test_a_page_it_couldnt_prepare_is_still_told_to_run_so_it_never_stays_held():
524
+ def refused(params, session):
525
+ raise DevToolsError("Target closed")
526
+
527
+ devtools = StubDevTools({"Page.enable": refused})
528
+ control = BrowserControl(devtools, "agent", (1024, 768))
529
+ await control.attach()
530
+ devtools.send("Target.attachedToTarget", {"sessionId": "s-popup", "waitingForDebugger": True, **_page("popup")})
531
+ await asyncio.sleep(0)
532
+
533
+ assert [method for method, _, session in devtools.calls if session == "s-popup"] == [
534
+ "Page.enable", "Runtime.runIfWaitingForDebugger"]
535
+
536
+
537
+ class Painting(StubDevTools):
538
+ """A browser whose every tab has focus and paints, but those whose sessions are in ``blank``, which never answer a
539
+ capture; it records each capture's timeout."""
540
+
541
+ def __init__(self, blank=()):
542
+ super().__init__({"Runtime.evaluate": lambda params, session: {"result": {"value": ["visible", True]}}})
543
+ self.blank, self.timeouts = set(blank), []
544
+
545
+ async def call(self, method, params=None, *, session_id=None, timeout_s=None):
546
+ if method != "Page.captureScreenshot":
547
+ return await super().call(method, params, session_id=session_id, timeout_s=timeout_s)
548
+ self.timeouts.append(timeout_s)
549
+ if session_id in self.blank:
550
+ raise asyncio.TimeoutError
551
+ return {"data": f"png of {session_id}"}
552
+
553
+
554
+ async def _one_tab(devtools, url) -> BrowserControl:
555
+ control = BrowserControl(devtools, "agent")
556
+ await control.attach()
557
+ devtools.send("Target.targetCreated", _page("a", url))
558
+ devtools.send("Target.attachedToTarget", {"sessionId": "s-a", **_page("a", url)})
559
+ await asyncio.sleep(0)
560
+ return control
561
+
562
+
563
+ @pytest.mark.asyncio
564
+ @pytest.mark.parametrize("url, blank, png, timeout", [
565
+ ("https://mail.test/", (), "png of s-a", 3.0),
566
+ ("https://mail.test/", ("s-a",), None, 3.0),
567
+ ("", ("s-a",), None, 1.0),
568
+ ])
569
+ async def test_its_capture_is_the_active_tabs_page_or_none_for_one_that_paints_nothing_sooner_with_no_url(
570
+ url, blank, png, timeout):
571
+ devtools = Painting(blank)
572
+ control = await _one_tab(devtools, url)
573
+
574
+ assert await control.capture() == png and devtools.timeouts == [timeout]
575
+
576
+
577
+ @pytest.mark.asyncio
578
+ async def test_its_page_input_goes_to_the_active_tab_in_order_and_takes_only_input_calls():
579
+ devtools = Painting()
580
+ control = await _one_tab(devtools, "https://mail.test/")
581
+ events = [{"method": "Input.dispatchMouseEvent", "params": {"type": "mousePressed", "x": 1, "y": 2}},
582
+ {"method": "Input.insertText", "params": {"text": "hi"}}]
583
+
584
+ await control.dispatch(events)
585
+ with pytest.raises(InvalidArgument, match="Page.navigate"):
586
+ await control.dispatch([*events, {"method": "Page.navigate", "params": {"url": "https://elsewhere.test/"}}])
587
+
588
+ sent = [(method, params, session) for method, params, session in devtools.calls if method.startswith("Input.")]
589
+ assert sent == [("Input.dispatchMouseEvent", {"type": "mousePressed", "x": 1, "y": 2}, "s-a"),
590
+ ("Input.insertText", {"text": "hi"}, "s-a")]
591
+
592
+
593
+ @pytest.mark.asyncio
594
+ async def test_with_no_tab_open_its_capture_and_input_are_unavailable():
595
+ control = BrowserControl(Painting(), "agent")
596
+ await control.attach()
597
+
598
+ with pytest.raises(DeviceUnavailable, match="no tab open"):
599
+ await control.capture()
600
+ with pytest.raises(DeviceUnavailable, match="no tab open"):
601
+ await control.dispatch([])
602
+
603
+
604
+ def test_its_declaration_says_it_is_a_website_browser_and_another_apps_says_nothing():
605
+ assert Browser.declaration().browser is True and Mail.declaration().browser is None
506
606
 
507
607
 
508
608
  @pytest.mark.asyncio