real-browser-cli 0.14.3__tar.gz → 0.15.5__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 (217) hide show
  1. real_browser_cli-0.15.5/.gitea/workflows/publish.yml +51 -0
  2. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/.gitignore +5 -0
  3. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/PKG-INFO +22 -10
  4. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/README.md +21 -9
  5. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/__init__.py +1 -1
  6. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/async_sdk.py +29 -7
  7. real_browser_cli-0.15.5/browser_cli/auth/__init__.py +67 -0
  8. real_browser_cli-0.15.5/browser_cli/auth/agent.py +103 -0
  9. real_browser_cli-0.15.5/browser_cli/auth/keys.py +59 -0
  10. real_browser_cli-0.15.5/browser_cli/auth/pq.py +65 -0
  11. real_browser_cli-0.15.5/browser_cli/auth/signing.py +42 -0
  12. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/client/__init__.py +2 -0
  13. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/client/core.py +27 -12
  14. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/client/targets.py +2 -0
  15. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/install.py +32 -11
  16. real_browser_cli-0.15.5/browser_cli/commands/rendering/__init__.py +58 -0
  17. real_browser_cli-0.15.5/browser_cli/commands/rendering/common.py +84 -0
  18. real_browser_cli-0.15.5/browser_cli/commands/rendering/labels.py +63 -0
  19. real_browser_cli-0.15.5/browser_cli/commands/rendering/tabs_tree.py +185 -0
  20. real_browser_cli-0.15.5/browser_cli/commands/rendering/windows_tree.py +38 -0
  21. real_browser_cli-0.15.5/browser_cli/commands/script.py +70 -0
  22. real_browser_cli-0.15.5/browser_cli/commands/tabs.py +219 -0
  23. real_browser_cli-0.15.5/browser_cli/commands/windows.py +63 -0
  24. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/constants.py +7 -1
  25. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/markdown/html.py +0 -20
  26. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/models.py +6 -0
  27. real_browser_cli-0.15.5/browser_cli/remote/auth.py +145 -0
  28. real_browser_cli-0.15.5/browser_cli/remote/socket.py +52 -0
  29. real_browser_cli-0.15.5/browser_cli/remote/transport.py +59 -0
  30. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/base.py +3 -3
  31. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/decorators.py +5 -5
  32. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/factories.py +14 -0
  33. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/routing.py +22 -3
  34. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/tabs.py +19 -0
  35. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/workflow_decorators.py +35 -14
  36. real_browser_cli-0.15.5/browser_cli/serve/challenge.py +31 -0
  37. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/serve/control.py +13 -5
  38. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/serve/runtime.py +1 -25
  39. real_browser_cli-0.15.5/browser_cli/transport/__init__.py +72 -0
  40. real_browser_cli-0.15.5/browser_cli/transport/binary.py +44 -0
  41. real_browser_cli-0.15.5/browser_cli/transport/codecs.py +84 -0
  42. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/manifest.json +7 -1
  43. real_browser_cli-0.15.5/extension/src/browser-api.ts +31 -0
  44. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/classes/CommandRegistry.ts +2 -1
  45. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/classes/JobManager.ts +6 -5
  46. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/classes/NativeConnection.ts +15 -14
  47. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/autosave.ts +23 -21
  48. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/dom.ts +7 -2
  49. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/extension.ts +4 -3
  50. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/groups.ts +20 -19
  51. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/navigation.ts +44 -19
  52. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/session-snapshot.ts +6 -4
  53. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/session.ts +22 -19
  54. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/tabs-query.ts +9 -8
  55. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/tabs.ts +26 -24
  56. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/windows.ts +7 -5
  57. real_browser_cli-0.15.5/extension/src/content/markdown/code.ts +63 -0
  58. real_browser_cli-0.15.5/extension/src/content/markdown/index.ts +9 -0
  59. real_browser_cli-0.15.5/extension/src/content/markdown/renderer.ts +217 -0
  60. real_browser_cli-0.15.5/extension/src/content/markdown/root.ts +47 -0
  61. real_browser_cli-0.15.5/extension/src/content/markdown/utils.ts +85 -0
  62. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/core/group-helpers.ts +6 -3
  63. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/core/index.ts +1 -0
  64. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/core/scripting.ts +6 -4
  65. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/core/storage.ts +5 -4
  66. real_browser_cli-0.15.5/extension/src/core/tab-groups.ts +57 -0
  67. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/core/tab-helpers.ts +18 -15
  68. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/core/throttle.ts +4 -3
  69. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/index.ts +2 -1
  70. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/types/command-args.ts +1 -1
  71. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/types/index.ts +1 -0
  72. real_browser_cli-0.15.5/extension/src/types/webextension.ts +41 -0
  73. real_browser_cli-0.15.5/extension/test/browser-api.test.ts +43 -0
  74. real_browser_cli-0.15.5/extension/test/clients.test.ts +86 -0
  75. real_browser_cli-0.15.5/extension/test/navigation.test.ts +66 -0
  76. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/package-lock.json +8 -0
  77. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/package.json +4 -1
  78. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/pyproject.toml +1 -1
  79. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/scripts/package_extension.py +21 -10
  80. real_browser_cli-0.15.5/scripts/package_verified_crx.sh +102 -0
  81. real_browser_cli-0.15.5/scripts/setup_verified_crx_key.sh +83 -0
  82. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_api.py +55 -2
  83. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_cli.py +108 -3
  84. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_client.py +3 -1
  85. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_extension_error_page_handling.py +29 -5
  86. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_extension_packaging.py +21 -0
  87. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_new_feature_commands.py +84 -1
  88. real_browser_cli-0.15.5/tests/test_rendering.py +91 -0
  89. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tsconfig.json +1 -1
  90. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/uv.lock +1 -1
  91. real_browser_cli-0.14.3/.gitea/workflows/publish.yml +0 -28
  92. real_browser_cli-0.14.3/browser_cli/auth.py +0 -263
  93. real_browser_cli-0.14.3/browser_cli/commands/script.py +0 -68
  94. real_browser_cli-0.14.3/browser_cli/commands/tabs.py +0 -252
  95. real_browser_cli-0.14.3/browser_cli/commands/windows.py +0 -87
  96. real_browser_cli-0.14.3/browser_cli/remote/transport.py +0 -230
  97. real_browser_cli-0.14.3/browser_cli/transport.py +0 -214
  98. real_browser_cli-0.14.3/extension/src/content/markdown.ts +0 -404
  99. real_browser_cli-0.14.3/webstore-assets/icon-128.png +0 -0
  100. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/.claude/settings.local.json +0 -0
  101. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/.gitea/workflows/package-extension.yml +0 -0
  102. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/.gitea/workflows/testing.yml +0 -0
  103. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/.python-version +0 -0
  104. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/CLAUDE.md +0 -0
  105. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/LICENSE +0 -0
  106. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/PRIVACY.md +0 -0
  107. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/cli.py +0 -0
  108. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/client/auth.py +0 -0
  109. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/client/messages.py +0 -0
  110. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/command_security.py +0 -0
  111. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/__init__.py +0 -0
  112. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/auth.py +0 -0
  113. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/clients.py +0 -0
  114. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/completion.py +0 -0
  115. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/doctor.py +0 -0
  116. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/dom.py +0 -0
  117. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/events.py +0 -0
  118. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/extension.py +0 -0
  119. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/extract.py +0 -0
  120. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/groups.py +0 -0
  121. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/navigate.py +0 -0
  122. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/page.py +0 -0
  123. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/perf.py +0 -0
  124. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/raw.py +0 -0
  125. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/remote.py +0 -0
  126. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/search.py +0 -0
  127. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/serve.py +0 -0
  128. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/serve_http.py +0 -0
  129. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/session.py +0 -0
  130. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/storage.py +0 -0
  131. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/watch.py +0 -0
  132. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/commands/workspace.py +0 -0
  133. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/compat/__init__.py +0 -0
  134. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/compat/auth.py +0 -0
  135. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/compat/commands.py +0 -0
  136. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/endpoints.py +0 -0
  137. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/errors.py +0 -0
  138. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/framing.py +0 -0
  139. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/local_transport.py +0 -0
  140. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/markdown/__init__.py +0 -0
  141. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/markdown/render.py +0 -0
  142. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/native/__init__.py +0 -0
  143. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/native/host.py +0 -0
  144. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/native/local_server.py +0 -0
  145. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/native/protocol.py +0 -0
  146. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/platform.py +0 -0
  147. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/registry.py +0 -0
  148. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/remote/__init__.py +0 -0
  149. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/remote/registry.py +0 -0
  150. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/__init__.py +0 -0
  151. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/browser_data.py +0 -0
  152. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/dom.py +0 -0
  153. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/extension.py +0 -0
  154. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/groups.py +0 -0
  155. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/navigation.py +0 -0
  156. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/perf.py +0 -0
  157. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/session.py +0 -0
  158. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/sdk/windows.py +0 -0
  159. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/serve/__init__.py +0 -0
  160. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/serve/auth.py +0 -0
  161. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/serve/logging.py +0 -0
  162. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/serve/proxy.py +0 -0
  163. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/browser_cli/version_manager.py +0 -0
  164. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/examples/demo.py +0 -0
  165. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/examples/demo.sh +0 -0
  166. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/content.js +0 -0
  167. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/icon.svg +0 -0
  168. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/icons/icon-128.png +0 -0
  169. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/icons/icon-16.png +0 -0
  170. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/icons/icon-32.png +0 -0
  171. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/icons/icon-48.png +0 -0
  172. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/classes/CommandGroup.ts +0 -0
  173. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/browser-data.ts +0 -0
  174. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/injected.ts +0 -0
  175. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/commands/perf.ts +0 -0
  176. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/content/dispatch.ts +0 -0
  177. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/content/dom-ops.ts +0 -0
  178. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/content/extract.ts +0 -0
  179. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/content/page-info.ts +0 -0
  180. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/content-dispatch.ts +0 -0
  181. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/core/errors.ts +0 -0
  182. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/types/jobs.ts +0 -0
  183. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/types/json.ts +0 -0
  184. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/types/messages.ts +0 -0
  185. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/types/session.ts +0 -0
  186. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/src/types/tabs.ts +0 -0
  187. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/test/autosave.test.ts +0 -0
  188. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/test/chrome-mock.ts +0 -0
  189. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/extension/test/jobs.test.ts +0 -0
  190. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/justfile +0 -0
  191. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/scripts/gen_extension_key.py +0 -0
  192. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/shell.nix +0 -0
  193. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/conftest.py +0 -0
  194. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_auth.py +0 -0
  195. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_commands_cli.py +0 -0
  196. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_dom.py +0 -0
  197. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_extract.py +0 -0
  198. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_framing.py +0 -0
  199. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_groups.py +0 -0
  200. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_native_host.py +0 -0
  201. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_nav.py +0 -0
  202. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_page.py +0 -0
  203. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_perf.py +0 -0
  204. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_performance_integration.py +0 -0
  205. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_platform.py +0 -0
  206. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_refactor_boundaries.py +0 -0
  207. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_registry.py +0 -0
  208. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_remote_protocol_matrix.py +0 -0
  209. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_serve.py +0 -0
  210. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_session.py +0 -0
  211. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_storage.py +0 -0
  212. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_tabs.py +0 -0
  213. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_transport.py +0 -0
  214. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/tests/test_windows.py +0 -0
  215. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/webstore-assets/screenshots/screenshot-1.png +0 -0
  216. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/webstore-assets/screenshots/screenshot-2.png +0 -0
  217. {real_browser_cli-0.14.3 → real_browser_cli-0.15.5}/webstore-assets/screenshots/screenshot-3.png +0 -0
@@ -0,0 +1,51 @@
1
+ name: Build & Publish Package
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*.*.*" # triggers on version tags like v0.1.0, v1.2.3
7
+ workflow_dispatch: # allows manual trigger from the Gitea UI
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v5
19
+
20
+ - name: Build Gitea package
21
+ run: |
22
+ # Keep the public/PyPI distribution as real-browser-cli in the repo,
23
+ # but publish the private Gitea package under browser-cli.
24
+ python - <<'PY'
25
+ from pathlib import Path
26
+
27
+ replacements = {
28
+ Path("pyproject.toml"): (
29
+ 'name = "real-browser-cli"',
30
+ 'name = "browser-cli"',
31
+ ),
32
+ Path("browser_cli/constants.py"): (
33
+ 'PYPI_PACKAGE_NAME = "real-browser-cli"',
34
+ 'PYPI_PACKAGE_NAME = "browser-cli"',
35
+ ),
36
+ }
37
+
38
+ for path, (old, new) in replacements.items():
39
+ text = path.read_text()
40
+ if old not in text:
41
+ raise SystemExit(f"expected text not found in {path}: {old}")
42
+ path.write_text(text.replace(old, new, 1))
43
+ PY
44
+ uv build
45
+
46
+ - name: Publish to Gitea
47
+ run: |
48
+ uv publish \
49
+ --publish-url "${{ vars.REGISTRY_URL }}/api/packages/${{ github.repository_owner }}/pypi" \
50
+ --username "${{ github.repository_owner }}" \
51
+ --password "${{ secrets.ACTION_ACCESS_TOKEN }}"
@@ -5,6 +5,11 @@ extension/test-dist/
5
5
  node_modules/
6
6
  dist/
7
7
 
8
+ # Local secrets / signing keys
9
+ secrets/
10
+ *.pem
11
+ *.pem.gpg
12
+
8
13
  # Python
9
14
  __pycache__/
10
15
  *.pyc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: real-browser-cli
3
- Version: 0.14.3
3
+ Version: 0.15.5
4
4
  Summary: Control your real running browser from the terminal or Python SDK
5
5
  Project-URL: Homepage, https://git.yiprawr.dev/Automatisation/browser-cli
6
6
  Project-URL: Repository, https://git.yiprawr.dev/Automatisation/browser-cli
@@ -143,7 +143,7 @@ Every response:
143
143
 
144
144
  ## Installation
145
145
 
146
- **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi
146
+ **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
147
147
 
148
148
  ### Install with uv
149
149
  Once published on PyPI, install the CLI as a uv tool:
@@ -151,7 +151,7 @@ Once published on PyPI, install the CLI as a uv tool:
151
151
  ```sh
152
152
  uv tool install real-browser-cli
153
153
  browser-cli --version
154
- browser-cli install brave # or: chrome, chromium, edge, vivaldi
154
+ browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
155
155
  ```
156
156
 
157
157
  The PyPI package is named `real-browser-cli`; the installed command is still `browser-cli`.
@@ -173,12 +173,12 @@ uv tool upgrade real-browser-cli
173
173
  git clone <repo>
174
174
  cd browser-cli
175
175
  uv sync
176
- uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi
176
+ uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
177
177
  ```
178
178
 
179
179
  The `install` command will:
180
180
  1. Ask you to load the browser-specific extension package
181
- 2. For Chromium-family browsers, ask you to paste the extension ID shown on the extension card
181
+ 2. Show the stable extension ID used by that browser family
182
182
  3. Write the native messaging manifest to your OS so the browser can find the host
183
183
  4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
184
184
 
@@ -608,11 +608,23 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
608
608
  Packaging:
609
609
 
610
610
  ```bash
611
- npm run package:extension # testing/unpacked zip, keeps manifest.key for stable native-messaging ID
612
- npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
611
+ npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
612
+ npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
613
+ npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
614
+ npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
613
615
  ```
614
616
 
615
- Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`.
617
+ Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key:
618
+
619
+ ```bash
620
+ scripts/setup_verified_crx_key.sh --recipient '<your GPG key id or email>'
621
+ # Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads.
622
+ npm run package:extension:webstore:verified
623
+ ```
624
+
625
+ The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip.
626
+
627
+ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons.
616
628
 
617
629
  ---
618
630
 
@@ -620,8 +632,8 @@ Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `
620
632
 
621
633
  - **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
622
634
  - **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
623
- - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
624
- - **Linux and macOS only** — Windows native messaging paths are not yet handled.
635
+ - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
636
+ - **Firefox support is experimental**. Basic tab/window/navigation/native-messaging support is wired, including tab-group APIs on supported Firefox versions.
625
637
 
626
638
  ---
627
639
 
@@ -50,7 +50,7 @@ Every response:
50
50
 
51
51
  ## Installation
52
52
 
53
- **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi
53
+ **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
54
54
 
55
55
  ### Install with uv
56
56
  Once published on PyPI, install the CLI as a uv tool:
@@ -58,7 +58,7 @@ Once published on PyPI, install the CLI as a uv tool:
58
58
  ```sh
59
59
  uv tool install real-browser-cli
60
60
  browser-cli --version
61
- browser-cli install brave # or: chrome, chromium, edge, vivaldi
61
+ browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
62
62
  ```
63
63
 
64
64
  The PyPI package is named `real-browser-cli`; the installed command is still `browser-cli`.
@@ -80,12 +80,12 @@ uv tool upgrade real-browser-cli
80
80
  git clone <repo>
81
81
  cd browser-cli
82
82
  uv sync
83
- uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi
83
+ uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
84
84
  ```
85
85
 
86
86
  The `install` command will:
87
87
  1. Ask you to load the browser-specific extension package
88
- 2. For Chromium-family browsers, ask you to paste the extension ID shown on the extension card
88
+ 2. Show the stable extension ID used by that browser family
89
89
  3. Write the native messaging manifest to your OS so the browser can find the host
90
90
  4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
91
91
 
@@ -515,11 +515,23 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
515
515
  Packaging:
516
516
 
517
517
  ```bash
518
- npm run package:extension # testing/unpacked zip, keeps manifest.key for stable native-messaging ID
519
- npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
518
+ npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
519
+ npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
520
+ npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
521
+ npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
520
522
  ```
521
523
 
522
- Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`.
524
+ Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key:
525
+
526
+ ```bash
527
+ scripts/setup_verified_crx_key.sh --recipient '<your GPG key id or email>'
528
+ # Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads.
529
+ npm run package:extension:webstore:verified
530
+ ```
531
+
532
+ The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip.
533
+
534
+ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons.
523
535
 
524
536
  ---
525
537
 
@@ -527,8 +539,8 @@ Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `
527
539
 
528
540
  - **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
529
541
  - **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
530
- - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
531
- - **Linux and macOS only** — Windows native messaging paths are not yet handled.
542
+ - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
543
+ - **Firefox support is experimental**. Basic tab/window/navigation/native-messaging support is wired, including tab-group APIs on supported Firefox versions.
532
544
 
533
545
  ---
534
546
 
@@ -36,7 +36,7 @@ Commands are grouped into namespaces on the client:
36
36
  """
37
37
  from collections.abc import Callable
38
38
 
39
- from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async
39
+ from browser_cli.client import active_browser_targets, remote_browser_targets, remote_targets_for_alias, send_command, send_command_async
40
40
  from browser_cli.errors import BrowserNotConnected
41
41
  from browser_cli.models import BrowserCounts, Group, Tab
42
42
  from browser_cli.sdk import (
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  import asyncio
13
13
  import functools
14
14
  from collections.abc import Callable
15
- from typing import TypeVar
15
+ from typing import TypeVar, cast
16
16
 
17
17
  from browser_cli.models import Group, Tab
18
18
  from browser_cli.sdk import NAMESPACE_NAMES
@@ -74,7 +74,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
74
74
  finally:
75
75
  if cleanup is not None:
76
76
  await self._maybe_await(cleanup(value))
77
- return wrapper # type: ignore[return-value]
77
+ return cast(F, wrapper)
78
78
  return decorator(func) if func is not None else decorator
79
79
 
80
80
  def new_tab(
@@ -117,7 +117,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
117
117
  finally:
118
118
  if previous:
119
119
  await self._c.perf.set_profile(previous)
120
- return wrapper # type: ignore[return-value]
120
+ return cast(F, wrapper)
121
121
  return decorator
122
122
 
123
123
  def retry(
@@ -142,8 +142,8 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
142
142
  raise
143
143
  if delay > 0:
144
144
  await asyncio.sleep(delay)
145
- raise last_error # type: ignore[misc]
146
- return wrapper # type: ignore[return-value]
145
+ raise cast(BaseException, last_error)
146
+ return cast(F, wrapper)
147
147
  return decorator
148
148
 
149
149
  class AsyncBrowserCLI:
@@ -220,18 +220,40 @@ class AsyncBrowserCLI:
220
220
  async def clients(self) -> list[dict]:
221
221
  return await self._cmd("clients.list", {})
222
222
 
223
- def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab:
223
+ def tab_from(
224
+ self,
225
+ data: dict,
226
+ *,
227
+ browser_profile: str | None = None,
228
+ browser_name: str | None = None,
229
+ browser_remote: str | None = None,
230
+ browser_type: str | None = None,
231
+ browser_group: str | None = None,
232
+ ) -> Tab:
224
233
  return self._sync.tab_from(
225
234
  data,
226
235
  browser_profile=browser_profile,
227
236
  browser_name=browser_name,
228
237
  browser_remote=browser_remote,
238
+ browser_type=browser_type,
239
+ browser_group=browser_group,
229
240
  )
230
241
 
231
- def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group:
242
+ def group_from(
243
+ self,
244
+ data: dict,
245
+ *,
246
+ browser_profile: str | None = None,
247
+ browser_name: str | None = None,
248
+ browser_remote: str | None = None,
249
+ browser_type: str | None = None,
250
+ browser_group: str | None = None,
251
+ ) -> Group:
232
252
  return self._sync.group_from(
233
253
  data,
234
254
  browser_profile=browser_profile,
235
255
  browser_name=browser_name,
236
256
  browser_remote=browser_remote,
257
+ browser_type=browser_type,
258
+ browser_group=browser_group,
237
259
  )
@@ -0,0 +1,67 @@
1
+ """Public auth API for browser-cli.
2
+
3
+ Implementation lives in focused modules:
4
+ - ``auth.agent``: SSH-agent/YubiKey helpers
5
+ - ``auth.keys``: file keys and authorized_keys management
6
+ - ``auth.signing``: canonical payload signing/verification
7
+ - ``auth.pq``: ML-KEM KEX and encrypted transport helpers
8
+ """
9
+ from browser_cli.auth.agent import (
10
+ AgentKey,
11
+ agent_find_key,
12
+ agent_list_keys,
13
+ agent_roundtrip as _agent_roundtrip,
14
+ agent_sign_raw,
15
+ pack_ssh_string as _pack_str,
16
+ unpack_ssh_string as _unpack_str,
17
+ )
18
+ from browser_cli.auth.keys import (
19
+ add_authorized_key,
20
+ generate_keypair,
21
+ load_authorized_keys,
22
+ load_authorized_keys_with_names,
23
+ load_private_key,
24
+ public_key_hex,
25
+ )
26
+ from browser_cli.auth.pq import (
27
+ new_nonce,
28
+ pq_decrypt,
29
+ pq_encrypt,
30
+ pq_kex_client_encapsulate,
31
+ pq_kex_server_decapsulate,
32
+ pq_kex_server_keypair,
33
+ pq_transport_key as _pq_transport_key,
34
+ )
35
+ from browser_cli.auth.signing import (
36
+ auth_message as _auth_message,
37
+ canonical_payload,
38
+ sign,
39
+ verify,
40
+ )
41
+ from browser_cli.constants import DEFAULT_AUTHORIZED_KEYS_PATH, DEFAULT_KEY_PATH, PQ_KEX_ALG, PQ_TRANSPORT_ALG
42
+
43
+ __all__ = [
44
+ "AgentKey",
45
+ "DEFAULT_AUTHORIZED_KEYS_PATH",
46
+ "DEFAULT_KEY_PATH",
47
+ "PQ_KEX_ALG",
48
+ "PQ_TRANSPORT_ALG",
49
+ "add_authorized_key",
50
+ "agent_find_key",
51
+ "agent_list_keys",
52
+ "agent_sign_raw",
53
+ "canonical_payload",
54
+ "generate_keypair",
55
+ "load_authorized_keys",
56
+ "load_authorized_keys_with_names",
57
+ "load_private_key",
58
+ "new_nonce",
59
+ "pq_decrypt",
60
+ "pq_encrypt",
61
+ "pq_kex_client_encapsulate",
62
+ "pq_kex_server_decapsulate",
63
+ "pq_kex_server_keypair",
64
+ "public_key_hex",
65
+ "sign",
66
+ "verify",
67
+ ]
@@ -0,0 +1,103 @@
1
+ """SSH-agent backed Ed25519 key helpers."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import socket
6
+ import struct
7
+ from dataclasses import dataclass
8
+
9
+ from browser_cli.constants import (
10
+ SSH_AGENT_IDENTITIES_ANSWER,
11
+ SSH_AGENT_SIGN_RESPONSE,
12
+ SSH_AGENTC_REQUEST_IDENTITIES,
13
+ SSH_AGENTC_SIGN_REQUEST,
14
+ )
15
+
16
+ def pack_ssh_string(value: bytes) -> bytes:
17
+ return struct.pack(">I", len(value)) + value
18
+
19
+ def unpack_ssh_string(data: bytes, offset: int) -> tuple[bytes, int]:
20
+ length = struct.unpack_from(">I", data, offset)[0]
21
+ return data[offset + 4 : offset + 4 + length], offset + 4 + length
22
+
23
+ def agent_roundtrip(msg: bytes) -> bytes:
24
+ sock_path = os.environ.get("SSH_AUTH_SOCK")
25
+ if not sock_path:
26
+ raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
27
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
28
+ sock.settimeout(10)
29
+ sock.connect(sock_path)
30
+ sock.sendall(struct.pack(">I", len(msg)) + msg)
31
+ raw_len = b""
32
+ while len(raw_len) < 4:
33
+ chunk = sock.recv(4 - len(raw_len))
34
+ if not chunk:
35
+ raise RuntimeError("SSH agent closed connection")
36
+ raw_len += chunk
37
+ length = struct.unpack(">I", raw_len)[0]
38
+ response = b""
39
+ while len(response) < length:
40
+ chunk = sock.recv(length - len(response))
41
+ if not chunk:
42
+ raise RuntimeError("SSH agent closed connection mid-response")
43
+ response += chunk
44
+ return response
45
+
46
+ @dataclass
47
+ class AgentKey:
48
+ """Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
49
+ blob: bytes
50
+ comment: str
51
+
52
+ @property
53
+ def pubkey_bytes(self) -> bytes:
54
+ _algo, offset = unpack_ssh_string(self.blob, 0)
55
+ key_bytes, _ = unpack_ssh_string(self.blob, offset)
56
+ return key_bytes
57
+
58
+ def agent_list_keys() -> list[AgentKey]:
59
+ """Return all Ed25519 keys currently held by the SSH agent."""
60
+ response = agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
61
+ if response[0] != SSH_AGENT_IDENTITIES_ANSWER:
62
+ raise RuntimeError(f"Unexpected agent response: {response[0]}")
63
+ key_count = struct.unpack_from(">I", response, 1)[0]
64
+ keys: list[AgentKey] = []
65
+ offset = 5
66
+ for _ in range(key_count):
67
+ blob, offset = unpack_ssh_string(response, offset)
68
+ comment, offset = unpack_ssh_string(response, offset)
69
+ algo, _ = unpack_ssh_string(blob, 0)
70
+ if algo == b"ssh-ed25519":
71
+ keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
72
+ return keys
73
+
74
+ def agent_find_key(selector: str | None = None) -> AgentKey | None:
75
+ """Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
76
+ try:
77
+ keys = agent_list_keys()
78
+ except Exception:
79
+ return None
80
+ for key in keys:
81
+ if key.comment == "(none)":
82
+ continue
83
+ if selector is None or selector in key.comment:
84
+ return key
85
+ return None
86
+
87
+ def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
88
+ """Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
89
+ msg = (
90
+ bytes([SSH_AGENTC_SIGN_REQUEST])
91
+ + pack_ssh_string(key.blob)
92
+ + pack_ssh_string(data)
93
+ + struct.pack(">I", 0)
94
+ )
95
+ response = agent_roundtrip(msg)
96
+ if response[0] != SSH_AGENT_SIGN_RESPONSE:
97
+ raise RuntimeError(f"SSH agent refused to sign (response code {response[0]})")
98
+ sig_blob, _ = unpack_ssh_string(response, 1)
99
+ _algo, sig_offset = unpack_ssh_string(sig_blob, 0)
100
+ raw_sig, _ = unpack_ssh_string(sig_blob, sig_offset)
101
+ if len(raw_sig) != 64:
102
+ raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
103
+ return raw_sig
@@ -0,0 +1,59 @@
1
+ """File-based Ed25519 keys and authorized_keys helpers."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
7
+ from cryptography.hazmat.primitives.serialization import (
8
+ Encoding,
9
+ NoEncryption,
10
+ PrivateFormat,
11
+ PublicFormat,
12
+ load_pem_private_key,
13
+ )
14
+
15
+ from browser_cli.auth.agent import AgentKey
16
+
17
+ def generate_keypair() -> tuple[bytes, str]:
18
+ """Return (private_key_pem_bytes, public_key_hex)."""
19
+ private_key = Ed25519PrivateKey.generate()
20
+ pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
21
+ public_hex = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
22
+ return pem, public_hex
23
+
24
+ def load_private_key(path: Path) -> Ed25519PrivateKey:
25
+ return load_pem_private_key(path.read_bytes(), password=None)
26
+
27
+ def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
28
+ if isinstance(key, AgentKey):
29
+ return key.pubkey_bytes.hex()
30
+ return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
31
+
32
+ def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
33
+ """Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
34
+ if not path.exists():
35
+ return []
36
+ result = []
37
+ for line in path.read_text(encoding="utf-8").splitlines():
38
+ line = line.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ parts = line.split(None, 1)
42
+ pubkey = parts[0]
43
+ name = parts[1].strip() if len(parts) > 1 else ""
44
+ result.append((pubkey, name))
45
+ return result
46
+
47
+ def load_authorized_keys(path: Path) -> list[str]:
48
+ return [pubkey for pubkey, _name in load_authorized_keys_with_names(path)]
49
+
50
+ def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
51
+ """Append pub_hex to authorized_keys. Returns False if already present."""
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)}
54
+ if pub_hex in existing:
55
+ return False
56
+ line = (f"{pub_hex} {name}".rstrip()) + "\n"
57
+ with open(path, "a", encoding="utf-8") as file:
58
+ file.write(line)
59
+ return True
@@ -0,0 +1,65 @@
1
+ """Post-quantum ML-KEM key exchange and app-layer transport encryption."""
2
+ from __future__ import annotations
3
+
4
+ import secrets
5
+
6
+ from cryptography.hazmat.primitives import hashes
7
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
8
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
9
+
10
+ from browser_cli.constants import PQ_TRANSPORT_ALG
11
+
12
+ def pq_kex_server_keypair():
13
+ """Return an ephemeral ML-KEM-768 private key and raw public key bytes.
14
+
15
+ Returns ``None`` when the installed cryptography/OpenSSL backend does not
16
+ support ML-KEM yet. The serve/client protocol treats this as graceful
17
+ downgrade instead of breaking local installs on older OpenSSL builds.
18
+ """
19
+ try:
20
+ from cryptography.hazmat.primitives.asymmetric import mlkem
21
+ private_key = mlkem.MLKEM768PrivateKey.generate()
22
+ public_key = private_key.public_key().public_bytes_raw()
23
+ return private_key, public_key
24
+ except Exception:
25
+ return None
26
+
27
+ def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
28
+ """Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
29
+ from cryptography.hazmat.primitives.asymmetric import mlkem
30
+ public_key = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
31
+ shared_secret, ciphertext = public_key.encapsulate()
32
+ return ciphertext.hex(), shared_secret
33
+
34
+ def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
35
+ """Decapsulate a client ML-KEM ciphertext and return the shared secret."""
36
+ return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
37
+
38
+ def pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
39
+ return HKDF(
40
+ algorithm=hashes.SHA256(),
41
+ length=32,
42
+ salt=None,
43
+ info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
44
+ ).derive(shared_secret)
45
+
46
+ def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
47
+ """Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
48
+ nonce = secrets.token_bytes(12)
49
+ key = pq_transport_key(shared_secret, direction)
50
+ ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
51
+ return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
52
+
53
+ def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
54
+ """Decrypt an app-layer frame produced by pq_encrypt()."""
55
+ if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
56
+ raise ValueError("unsupported encrypted transport envelope")
57
+ key = pq_transport_key(shared_secret, direction)
58
+ return ChaCha20Poly1305(key).decrypt(
59
+ bytes.fromhex(str(envelope["nonce"])),
60
+ bytes.fromhex(str(envelope["ciphertext"])),
61
+ None,
62
+ )
63
+
64
+ def new_nonce() -> str:
65
+ return secrets.token_hex(32)
@@ -0,0 +1,42 @@
1
+ """Canonical browser-cli auth payload signing and verification."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+
7
+ from cryptography.exceptions import InvalidSignature
8
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
9
+
10
+ from browser_cli.auth.agent import AgentKey, agent_sign_raw
11
+
12
+ def canonical_payload(msg: dict) -> bytes:
13
+ """Deterministic JSON encoding of msg without auth protocol fields."""
14
+ return json.dumps(
15
+ {key: value for key, value in msg.items() if key not in {"pubkey", "sig", "pq_kex"}},
16
+ sort_keys=True,
17
+ separators=(",", ":"),
18
+ ).encode("utf-8")
19
+
20
+ def auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
21
+ """Bytes signed for auth; optionally binds a post-quantum KEX secret."""
22
+ data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
23
+ if pq_shared_secret is not None:
24
+ data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
25
+ return data
26
+
27
+ def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
28
+ """Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
29
+ data = auth_message(nonce, msg, pq_shared_secret)
30
+ if isinstance(key, AgentKey):
31
+ return agent_sign_raw(key, data)
32
+ return key.sign(data)
33
+
34
+ def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
35
+ """Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
36
+ try:
37
+ pub_bytes = bytes.fromhex(pub_hex)
38
+ pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
39
+ pub_key.verify(bytes.fromhex(sig_hex), auth_message(nonce, msg, pq_shared_secret))
40
+ return True
41
+ except (InvalidSignature, ValueError):
42
+ return False
@@ -10,6 +10,7 @@ from browser_cli.client.core import (
10
10
  remote_browser_targets,
11
11
  remote_browser_targets_async,
12
12
  remote_target_for_alias,
13
+ remote_targets_for_alias,
13
14
  send_command,
14
15
  send_command_async,
15
16
  )
@@ -42,6 +43,7 @@ __all__ = [
42
43
  "remote_browser_targets",
43
44
  "remote_browser_targets_async",
44
45
  "remote_target_for_alias",
46
+ "remote_targets_for_alias",
45
47
  "send_command",
46
48
  "send_command_async",
47
49
  ]