real-browser-cli 0.16.3__tar.gz → 0.16.4__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 (246) hide show
  1. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/.gitea/workflows/testing.yml +2 -2
  2. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/PKG-INFO +8 -3
  3. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/README.md +7 -2
  4. real_browser_cli-0.16.4/browser_cli/auth/server_identity.py +57 -0
  5. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/client/core.py +29 -0
  6. real_browser_cli-0.16.4/browser_cli/commands/remote.py +153 -0
  7. real_browser_cli-0.16.4/browser_cli/compat/auth.py +30 -0
  8. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/compat/commands.py +19 -16
  9. real_browser_cli-0.16.4/browser_cli/remote/known_hosts.py +108 -0
  10. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/remote/registry.py +28 -7
  11. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/remote/transport.py +7 -1
  12. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/serve/challenge.py +4 -0
  13. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/manifest.json +1 -1
  14. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/tabs-query.ts +22 -2
  15. real_browser_cli-0.16.4/extension/test/url-match.test.ts +43 -0
  16. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/README.md +42 -7
  17. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/credentials/BrowserCliApi.credentials.ts +17 -0
  18. real_browser_cli-0.16.4/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts +870 -0
  19. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg +1 -1
  20. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/nodes/BrowserCli/protocol.ts +78 -0
  21. real_browser_cli-0.16.4/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts +303 -0
  22. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts +12 -2
  23. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/package-lock.json +2 -2
  24. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/package.json +1 -1
  25. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/test/protocol.test.ts +39 -0
  26. real_browser_cli-0.16.4/n8n-nodes-browser-cli/test/request.test.ts +189 -0
  27. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/test-dist/protocol.test.mjs +91 -0
  28. real_browser_cli-0.16.4/n8n-nodes-browser-cli/test-dist/request.test.mjs +413 -0
  29. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/pyproject.toml +1 -1
  30. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_client.py +49 -0
  31. real_browser_cli-0.16.4/tests/test_compat.py +48 -0
  32. real_browser_cli-0.16.4/tests/test_known_hosts.py +64 -0
  33. real_browser_cli-0.16.4/tests/test_remote_registry.py +42 -0
  34. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_serve.py +0 -27
  35. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/uv.lock +166 -181
  36. real_browser_cli-0.16.3/browser_cli/commands/remote.py +0 -78
  37. real_browser_cli-0.16.3/browser_cli/compat/auth.py +0 -50
  38. real_browser_cli-0.16.3/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts +0 -382
  39. real_browser_cli-0.16.3/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts +0 -129
  40. real_browser_cli-0.16.3/n8n-nodes-browser-cli/test/request.test.ts +0 -73
  41. real_browser_cli-0.16.3/n8n-nodes-browser-cli/test-dist/request.test.mjs +0 -147
  42. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/.claude/settings.local.json +0 -0
  43. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/.gitea/workflows/package-extension.yml +0 -0
  44. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/.gitea/workflows/publish.yml +0 -0
  45. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/.gitignore +0 -0
  46. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/.python-version +0 -0
  47. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/CLAUDE.md +0 -0
  48. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/LICENSE +0 -0
  49. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/PRIVACY.md +0 -0
  50. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/__init__.py +0 -0
  51. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/async_sdk.py +0 -0
  52. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/auth/__init__.py +0 -0
  53. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/auth/agent.py +0 -0
  54. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/auth/keys.py +0 -0
  55. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/auth/pq.py +0 -0
  56. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/auth/signing.py +0 -0
  57. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/cli.py +0 -0
  58. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/client/__init__.py +0 -0
  59. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/client/auth.py +0 -0
  60. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/client/messages.py +0 -0
  61. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/client/targets.py +0 -0
  62. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/command_security.py +0 -0
  63. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/__init__.py +0 -0
  64. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/auth.py +0 -0
  65. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/clients.py +0 -0
  66. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/completion.py +0 -0
  67. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/doctor.py +0 -0
  68. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/dom.py +0 -0
  69. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/events.py +0 -0
  70. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/extension.py +0 -0
  71. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/extract.py +0 -0
  72. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/groups.py +0 -0
  73. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/install.py +0 -0
  74. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/navigate.py +0 -0
  75. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/page.py +0 -0
  76. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/perf.py +0 -0
  77. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/raw.py +0 -0
  78. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/rendering/__init__.py +0 -0
  79. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/rendering/common.py +0 -0
  80. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/rendering/labels.py +0 -0
  81. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/rendering/tabs_tree.py +0 -0
  82. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/rendering/windows_tree.py +0 -0
  83. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/script.py +0 -0
  84. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/search.py +0 -0
  85. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/serve.py +0 -0
  86. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/serve_http.py +0 -0
  87. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/session.py +0 -0
  88. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/storage.py +0 -0
  89. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/tabs.py +0 -0
  90. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/watch.py +0 -0
  91. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/windows.py +0 -0
  92. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/commands/workspace.py +0 -0
  93. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/compat/__init__.py +0 -0
  94. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/constants.py +0 -0
  95. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/endpoints.py +0 -0
  96. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/errors.py +0 -0
  97. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/framing.py +0 -0
  98. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/local_transport.py +0 -0
  99. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/markdown/__init__.py +0 -0
  100. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/markdown/html.py +0 -0
  101. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/markdown/render.py +0 -0
  102. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/models.py +0 -0
  103. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/native/__init__.py +0 -0
  104. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/native/host.py +0 -0
  105. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/native/local_server.py +0 -0
  106. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/native/protocol.py +0 -0
  107. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/platform.py +0 -0
  108. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/registry.py +0 -0
  109. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/remote/__init__.py +0 -0
  110. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/remote/auth.py +0 -0
  111. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/remote/pool.py +0 -0
  112. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/remote/socket.py +0 -0
  113. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/__init__.py +0 -0
  114. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/base.py +0 -0
  115. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/browser_data.py +0 -0
  116. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/decorators.py +0 -0
  117. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/dom.py +0 -0
  118. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/extension.py +0 -0
  119. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/factories.py +0 -0
  120. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/groups.py +0 -0
  121. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/navigation.py +0 -0
  122. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/perf.py +0 -0
  123. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/routing.py +0 -0
  124. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/session.py +0 -0
  125. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/tabs.py +0 -0
  126. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/windows.py +0 -0
  127. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/sdk/workflow_decorators.py +0 -0
  128. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/search/__init__.py +0 -0
  129. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/search/engines.py +0 -0
  130. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/serve/__init__.py +0 -0
  131. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/serve/auth.py +0 -0
  132. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/serve/control.py +0 -0
  133. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/serve/logging.py +0 -0
  134. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/serve/proxy.py +0 -0
  135. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/serve/runtime.py +0 -0
  136. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/serve/security.py +0 -0
  137. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/transport/__init__.py +0 -0
  138. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/transport/binary.py +0 -0
  139. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/transport/codecs.py +0 -0
  140. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/browser_cli/version_manager.py +0 -0
  141. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/examples/demo.py +0 -0
  142. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/examples/demo.sh +0 -0
  143. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/content.js +0 -0
  144. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/icon.svg +0 -0
  145. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/icons/icon-128.png +0 -0
  146. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/icons/icon-16.png +0 -0
  147. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/icons/icon-32.png +0 -0
  148. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/icons/icon-48.png +0 -0
  149. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/browser-api.ts +0 -0
  150. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/classes/CommandGroup.ts +0 -0
  151. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/classes/CommandRegistry.ts +0 -0
  152. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/classes/JobManager.ts +0 -0
  153. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/classes/NativeConnection.ts +0 -0
  154. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/autosave.ts +0 -0
  155. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/browser-data.ts +0 -0
  156. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/dom.ts +0 -0
  157. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/extension.ts +0 -0
  158. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/groups.ts +0 -0
  159. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/injected.ts +0 -0
  160. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/navigation.ts +0 -0
  161. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/perf.ts +0 -0
  162. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/session-snapshot.ts +0 -0
  163. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/session.ts +0 -0
  164. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/tabs.ts +0 -0
  165. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/commands/windows.ts +0 -0
  166. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/dispatch.ts +0 -0
  167. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/dom-ops.ts +0 -0
  168. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/extract.ts +0 -0
  169. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/markdown/code.ts +0 -0
  170. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/markdown/index.ts +0 -0
  171. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/markdown/renderer.ts +0 -0
  172. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/markdown/root.ts +0 -0
  173. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/markdown/utils.ts +0 -0
  174. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content/page-info.ts +0 -0
  175. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/content-dispatch.ts +0 -0
  176. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/core/errors.ts +0 -0
  177. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/core/group-helpers.ts +0 -0
  178. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/core/index.ts +0 -0
  179. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/core/scripting.ts +0 -0
  180. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/core/storage.ts +0 -0
  181. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/core/tab-groups.ts +0 -0
  182. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/core/tab-helpers.ts +0 -0
  183. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/core/throttle.ts +0 -0
  184. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/index.ts +0 -0
  185. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/types/command-args.ts +0 -0
  186. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/types/index.ts +0 -0
  187. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/types/jobs.ts +0 -0
  188. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/types/json.ts +0 -0
  189. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/types/messages.ts +0 -0
  190. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/types/session.ts +0 -0
  191. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/types/tabs.ts +0 -0
  192. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/src/types/webextension.ts +0 -0
  193. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/test/autosave.test.ts +0 -0
  194. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/test/browser-api.test.ts +0 -0
  195. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/test/chrome-mock.ts +0 -0
  196. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/test/clients.test.ts +0 -0
  197. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/test/jobs.test.ts +0 -0
  198. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/test/navigation.test.ts +0 -0
  199. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/extension/test/paging.test.ts +0 -0
  200. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/justfile +0 -0
  201. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/.claude/settings.local.json +0 -0
  202. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/.gitignore +0 -0
  203. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/scripts/copy-assets.mjs +0 -0
  204. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/scripts/run-tests.mjs +0 -0
  205. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/n8n-nodes-browser-cli/tsconfig.json +0 -0
  206. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/package-lock.json +0 -0
  207. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/package.json +0 -0
  208. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/scripts/gen_extension_key.py +0 -0
  209. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/scripts/package_extension.py +0 -0
  210. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/scripts/package_verified_crx.sh +0 -0
  211. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/scripts/setup_verified_crx_key.sh +0 -0
  212. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/shell.nix +0 -0
  213. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/conftest.py +0 -0
  214. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_api.py +0 -0
  215. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_auth.py +0 -0
  216. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_cli.py +0 -0
  217. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_commands_cli.py +0 -0
  218. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_dom.py +0 -0
  219. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_extension_error_page_handling.py +0 -0
  220. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_extension_packaging.py +0 -0
  221. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_extract.py +0 -0
  222. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_framing.py +0 -0
  223. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_groups.py +0 -0
  224. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_markdown_security.py +0 -0
  225. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_native_host.py +0 -0
  226. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_nav.py +0 -0
  227. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_new_feature_commands.py +0 -0
  228. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_page.py +0 -0
  229. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_perf.py +0 -0
  230. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_performance_integration.py +0 -0
  231. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_platform.py +0 -0
  232. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_refactor_boundaries.py +0 -0
  233. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_registry.py +0 -0
  234. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_remote_pool.py +0 -0
  235. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_remote_protocol_matrix.py +0 -0
  236. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_rendering.py +0 -0
  237. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_serve_security.py +0 -0
  238. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_session.py +0 -0
  239. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_storage.py +0 -0
  240. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_tabs.py +0 -0
  241. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_transport.py +0 -0
  242. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tests/test_windows.py +0 -0
  243. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/tsconfig.json +0 -0
  244. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/webstore-assets/screenshots/screenshot-1.png +0 -0
  245. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/webstore-assets/screenshots/screenshot-2.png +0 -0
  246. {real_browser_cli-0.16.3 → real_browser_cli-0.16.4}/webstore-assets/screenshots/screenshot-3.png +0 -0
@@ -28,8 +28,8 @@ jobs:
28
28
  fail-fast: false
29
29
  matrix:
30
30
  browser-cli-client-version:
31
- - "0.9.3"
32
- - "0.9.5"
31
+ - "0.15.0"
32
+ - "0.16.0"
33
33
 
34
34
  steps:
35
35
  - name: Checkout
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: real-browser-cli
3
- Version: 0.16.3
3
+ Version: 0.16.4
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
@@ -290,8 +290,9 @@ browser-cli search so click choices
290
290
  ```sh
291
291
  browser-cli tabs list # list all open tabs (all windows)
292
292
  browser-cli tabs count # count all tabs
293
- browser-cli tabs count youtube # count tabs matching URL pattern
294
- browser-cli tabs filter youtube # list tabs matching URL pattern
293
+ browser-cli tabs count youtube # count tabs whose URL contains "youtube"
294
+ browser-cli tabs filter youtube # list tabs whose URL contains "youtube"
295
+ browser-cli tabs filter 'twitch.tv/*' # glob: list every twitch.tv tab
295
296
  browser-cli tabs query "pull request" # search tabs by URL or title
296
297
 
297
298
  browser-cli tabs active 1234 # switch browser focus to tab
@@ -313,6 +314,10 @@ browser-cli tabs sort --by time
313
314
  browser-cli tabs merge-windows # pull all tabs into the current window
314
315
  ```
315
316
 
317
+ > URL patterns for `tabs filter` / `tabs count` match against the full tab URL.
318
+ > A plain string is a case-sensitive substring (`youtube`); a pattern containing
319
+ > `*` or `?` is treated as a glob (`twitch.tv/*`, `*.twitch.tv`).
320
+
316
321
  ### Tab groups
317
322
  ```sh
318
323
  browser-cli groups list # list all tab groups
@@ -196,8 +196,9 @@ browser-cli search so click choices
196
196
  ```sh
197
197
  browser-cli tabs list # list all open tabs (all windows)
198
198
  browser-cli tabs count # count all tabs
199
- browser-cli tabs count youtube # count tabs matching URL pattern
200
- browser-cli tabs filter youtube # list tabs matching URL pattern
199
+ browser-cli tabs count youtube # count tabs whose URL contains "youtube"
200
+ browser-cli tabs filter youtube # list tabs whose URL contains "youtube"
201
+ browser-cli tabs filter 'twitch.tv/*' # glob: list every twitch.tv tab
201
202
  browser-cli tabs query "pull request" # search tabs by URL or title
202
203
 
203
204
  browser-cli tabs active 1234 # switch browser focus to tab
@@ -219,6 +220,10 @@ browser-cli tabs sort --by time
219
220
  browser-cli tabs merge-windows # pull all tabs into the current window
220
221
  ```
221
222
 
223
+ > URL patterns for `tabs filter` / `tabs count` match against the full tab URL.
224
+ > A plain string is a case-sensitive substring (`youtube`); a pattern containing
225
+ > `*` or `?` is treated as a glob (`twitch.tv/*`, `*.twitch.tv`).
226
+
222
227
  ### Tab groups
223
228
  ```sh
224
229
  browser-cli groups list # list all tab groups
@@ -0,0 +1,57 @@
1
+ """Persistent server identity keys for SSH-style remote host pinning."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from cryptography.exceptions import InvalidSignature
8
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
9
+ from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key
10
+
11
+ from browser_cli.constants import CONFIG_DIR
12
+
13
+ SERVER_IDENTITY_PATH = CONFIG_DIR / "server_identity.pem"
14
+
15
+ def load_or_create_server_identity(path: Path = SERVER_IDENTITY_PATH) -> Ed25519PrivateKey:
16
+ """Load the persistent serve identity key, creating it on first start."""
17
+ if path.exists():
18
+ key = load_pem_private_key(path.read_bytes(), password=None)
19
+ if not isinstance(key, Ed25519PrivateKey):
20
+ raise ValueError(f"server identity key is not Ed25519: {path}")
21
+ return key
22
+
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ key = Ed25519PrivateKey.generate()
25
+ pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
26
+ fd = path.open("xb")
27
+ try:
28
+ fd.write(pem)
29
+ finally:
30
+ fd.close()
31
+ path.chmod(0o600)
32
+ return key
33
+
34
+ def public_key_hex(key: Ed25519PrivateKey) -> str:
35
+ return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
36
+
37
+ def _signed_challenge_fields(challenge: dict) -> dict:
38
+ return {key: value for key, value in challenge.items() if key != "server_sig"}
39
+
40
+ def challenge_payload(challenge: dict) -> bytes:
41
+ """Canonical bytes signed by the server identity key."""
42
+ return json.dumps(_signed_challenge_fields(challenge), sort_keys=True, separators=(",", ":")).encode("utf-8")
43
+
44
+ def sign_challenge(challenge: dict, key: Ed25519PrivateKey) -> str:
45
+ return key.sign(challenge_payload(challenge)).hex()
46
+
47
+ def verify_challenge_signature(challenge: dict) -> bool:
48
+ pub_hex = challenge.get("server_pubkey")
49
+ sig_hex = challenge.get("server_sig")
50
+ if not isinstance(pub_hex, str) or not isinstance(sig_hex, str):
51
+ return False
52
+ try:
53
+ pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex))
54
+ pub.verify(bytes.fromhex(sig_hex), challenge_payload(challenge))
55
+ return True
56
+ except (InvalidSignature, ValueError):
57
+ return False
@@ -243,6 +243,35 @@ def collect_browser_clients(
243
243
  return rows
244
244
 
245
245
  if remote:
246
+ targets = remote_browser_targets(remote, key=key)
247
+ if browser_alias:
248
+ targets = [target for target in targets if target.profile == browser_alias or target.display_name == browser_alias]
249
+ if targets:
250
+ uncached = []
251
+ for target in targets:
252
+ cached = _cached_client_row(target)
253
+ if cached is not None:
254
+ rows.append(cached)
255
+ else:
256
+ uncached.append(target)
257
+ results = _run_concurrent([
258
+ (lambda t=t: _client_rows_async(
259
+ t.display_name,
260
+ profile=t.profile,
261
+ remote=remote,
262
+ key=key,
263
+ profile_group=t.display_group,
264
+ ))
265
+ for t in uncached
266
+ ])
267
+ for result in results:
268
+ if isinstance(result, (BrowserNotConnected, RuntimeError)):
269
+ continue
270
+ if isinstance(result, BaseException):
271
+ raise result
272
+ rows.extend(result)
273
+ return rows
274
+
246
275
  result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
247
276
  for item in result or []:
248
277
  row = dict(item)
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from browser_cli.errors import BrowserNotConnected
8
+
9
+ from browser_cli import BrowserCLI
10
+ from browser_cli.commands import handle_errors
11
+ from browser_cli.commands.rendering import print_browser_grouped_table_rows
12
+ from browser_cli.remote.known_hosts import fingerprint, load_known_hosts, remove_known_host, save_known_host
13
+ from browser_cli.remote.registry import load_remotes, remove_remote, save_remote, save_remote_key
14
+
15
+ console = Console()
16
+
17
+ def _print_remotes() -> None:
18
+ remotes = load_remotes()
19
+ if not remotes:
20
+ console.print("[yellow]No remembered remotes[/yellow]")
21
+ return
22
+ table = Table(show_header=True, header_style="bold cyan")
23
+ table.add_column("Endpoint")
24
+ table.add_column("Key")
25
+ for endpoint, cfg in sorted(remotes.items()):
26
+ table.add_row(endpoint, str(cfg.get("key", "")))
27
+ console.print(table)
28
+
29
+ def _remove_remote(endpoint: str, *, verb: str) -> None:
30
+ if not remove_remote(endpoint):
31
+ console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
32
+ return
33
+ console.print(f"[green]{verb} {endpoint}[/green]")
34
+
35
+ def _fetch_server_pubkey(endpoint: str) -> str:
36
+ from browser_cli.auth.server_identity import verify_challenge_signature
37
+ from browser_cli.remote.auth import parse_challenge
38
+ from browser_cli.remote.socket import connect_socket, recv_all
39
+
40
+ sock = connect_socket(endpoint)
41
+ try:
42
+ challenge, _nonce = parse_challenge(recv_all(sock) or b"")
43
+ finally:
44
+ sock.close()
45
+ if not isinstance(challenge, dict) or not isinstance(challenge.get("server_pubkey"), str):
46
+ raise BrowserNotConnected("remote server did not advertise a server identity key")
47
+ if not verify_challenge_signature(challenge):
48
+ raise BrowserNotConnected("remote server identity signature is invalid")
49
+ return str(challenge["server_pubkey"])
50
+
51
+ @click.group("remote")
52
+ def remote_group():
53
+ """Manage remembered browser-cli remote endpoints."""
54
+
55
+ @remote_group.command("status")
56
+ @click.argument("endpoint")
57
+ @click.option("--key", default=None, help="Key spec/path to use for this probe")
58
+ @handle_errors
59
+ def remote_status(endpoint, key):
60
+ """Probe a remote endpoint and show server/client status."""
61
+ client = BrowserCLI(remote=endpoint, key=key)
62
+ clients = [
63
+ {**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint}
64
+ for item in client.clients()
65
+ ]
66
+ columns = [
67
+ ("Browser", lambda item: item.get("name", "")),
68
+ ("Extension", lambda item: item.get("extensionVersion", "")),
69
+ ]
70
+ print_browser_grouped_table_rows(
71
+ clients,
72
+ columns,
73
+ console=console,
74
+ empty_message="[yellow]No browser clients found[/yellow]",
75
+ browser_getter=lambda item: item.get("profileLabel", ""),
76
+ group_getter=lambda item: item.get("profileGroup", ""),
77
+ browser_header="Profile",
78
+ )
79
+
80
+ @remote_group.command("add")
81
+ @click.argument("endpoint")
82
+ @click.option("--key", "key_spec", default=None, help="Key spec/path to remember for this endpoint")
83
+ def remote_add(endpoint, key_spec):
84
+ """Remember a remote endpoint for global multi-browser commands."""
85
+ save_remote(endpoint, key_spec)
86
+ if key_spec:
87
+ console.print(f"[green]Added remote {endpoint} with key {key_spec}[/green]")
88
+ else:
89
+ console.print(f"[green]Added remote {endpoint}[/green]")
90
+
91
+ @remote_group.command("trust")
92
+ @click.argument("endpoint")
93
+ @click.argument("key_spec")
94
+ def remote_trust(endpoint, key_spec):
95
+ """Remember which key spec to use for ENDPOINT."""
96
+ save_remote_key(endpoint, key_spec)
97
+ console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
98
+
99
+ @remote_group.command("list")
100
+ def remote_list():
101
+ """List remembered remote endpoints."""
102
+ _print_remotes()
103
+
104
+ @remote_group.command("trust-host")
105
+ @click.argument("endpoint")
106
+ @click.option("--pubkey", default=None, help="Pin this server public key instead of probing the endpoint")
107
+ @handle_errors
108
+ def remote_trust_host(endpoint, pubkey):
109
+ """Pin a remote server identity key, SSH known_hosts style."""
110
+ server_pubkey = pubkey or _fetch_server_pubkey(endpoint)
111
+ save_known_host(endpoint, server_pubkey)
112
+ console.print(f"[green]Trusted server {endpoint}[/green] [dim]{fingerprint(server_pubkey)}[/dim]")
113
+
114
+ @remote_group.command("known-hosts")
115
+ def remote_known_hosts():
116
+ """List pinned remote server identity keys."""
117
+ known = load_known_hosts()
118
+ if not known:
119
+ console.print("[yellow]No known remote server identities[/yellow]")
120
+ return
121
+ table = Table(show_header=True, header_style="bold cyan")
122
+ table.add_column("Endpoint")
123
+ table.add_column("Fingerprint")
124
+ table.add_column("Public Key")
125
+ for endpoint, pubkey in sorted(known.items()):
126
+ table.add_row(endpoint, fingerprint(pubkey), pubkey)
127
+ console.print(table)
128
+
129
+ @remote_group.command("untrust-host")
130
+ @click.argument("endpoint")
131
+ def remote_untrust_host(endpoint):
132
+ """Remove a pinned remote server identity key."""
133
+ if not remove_known_host(endpoint):
134
+ console.print(f"[yellow]Remote server {endpoint} is not in known hosts[/yellow]")
135
+ return
136
+ console.print(f"[green]Removed server identity for {endpoint}[/green]")
137
+
138
+ @remote_group.command("keys")
139
+ def remote_keys():
140
+ """List remembered remote key specs."""
141
+ _print_remotes()
142
+
143
+ @remote_group.command("remove")
144
+ @click.argument("endpoint")
145
+ def remote_remove(endpoint):
146
+ """Remove a remembered remote endpoint."""
147
+ _remove_remote(endpoint, verb="Removed")
148
+
149
+ @remote_group.command("revoke")
150
+ @click.argument("endpoint")
151
+ def remote_revoke(endpoint):
152
+ """Remove remembered key/config for ENDPOINT."""
153
+ _remove_remote(endpoint, verb="Revoked")
@@ -0,0 +1,30 @@
1
+ """
2
+ Auth-field normalizers — applied to the raw incoming message *before* the
3
+ auth check runs. Protocol fields (pubkey, sig, …) are still present here.
4
+
5
+ Add one entry per breaking auth-field change:
6
+ ("X.Y.Z", transformer_fn)
7
+
8
+ Entries must stay in ascending version order.
9
+
10
+ The registry is intentionally empty: the first public release was 0.14.1, so no
11
+ legacy-client shim has ever been needed. This module is the seam — add a tuple
12
+ here (and a unit test for it) the day a breaking auth-field change ships.
13
+ """
14
+ from __future__ import annotations
15
+ from typing import Callable
16
+ from browser_cli.version_manager import parse_version
17
+
18
+ # ── registry ──────────────────────────────────────────────────────────────────
19
+
20
+ _AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = []
21
+
22
+ def adapt_auth(msg: dict, client_version: str) -> dict:
23
+ """Apply all auth normalizers needed to bring msg up to the current format."""
24
+ if not _AUTH_COMPAT:
25
+ return msg
26
+ cv = parse_version(client_version)
27
+ for version, fn in _AUTH_COMPAT:
28
+ if cv < parse_version(version):
29
+ msg = fn(msg)
30
+ return msg
@@ -3,7 +3,7 @@ Command-format shims — applied to clean_msg (protocol fields already stripped)
3
3
  before forwarding to the native host, and to responses before sending back.
4
4
 
5
5
  Add one entry per breaking command-format change:
6
- ("X.Y.Z", request_fn, response_fn)
6
+ ("X.Y.Z", request_fn, response_fn)
7
7
 
8
8
  - request_fn(msg: dict) -> dict or None
9
9
  - response_fn(resp: bytes, command: str) -> bytes or None
@@ -11,33 +11,36 @@ Add one entry per breaking command-format change:
11
11
  Entries must stay in ascending version order.
12
12
  adapt_request walks forward (oldest first); adapt_response walks backward.
13
13
 
14
- Current baseline: 0.9.3 — no command-format shims needed yet.
14
+ The registry is intentionally empty: no command-format shim has been needed
15
+ since the first public release (0.14.1). This module is the seam — add a tuple
16
+ here (and a unit test for it) the day a breaking command-format change ships.
15
17
  """
16
18
  from __future__ import annotations
17
19
  from typing import Callable
18
20
  from browser_cli.version_manager import parse_version
19
21
 
20
-
21
22
  # ── registry ──────────────────────────────────────────────────────────────────
22
23
 
23
24
  _COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
24
- # ("1.0.0", _req_1_0_0, _resp_1_0_0),
25
+ # ("1.0.0", _req_1_0_0, _resp_1_0_0),
25
26
  ]
26
27
 
27
-
28
28
  def adapt_request(msg: dict, client_version: str) -> dict:
29
- """Upgrade a client message to the current browser command format."""
30
- cv = parse_version(client_version)
31
- for version, req_fn, _ in _COMPAT:
32
- if cv < parse_version(version) and req_fn is not None:
33
- msg = req_fn(msg)
29
+ """Upgrade a client message to the current browser command format."""
30
+ if not _COMPAT:
34
31
  return msg
35
-
32
+ cv = parse_version(client_version)
33
+ for version, req_fn, _ in _COMPAT:
34
+ if cv < parse_version(version) and req_fn is not None:
35
+ msg = req_fn(msg)
36
+ return msg
36
37
 
37
38
  def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
38
- """Downgrade a native-host response to the format the client expects."""
39
- cv = parse_version(client_version)
40
- for version, _, resp_fn in reversed(_COMPAT):
41
- if cv < parse_version(version) and resp_fn is not None:
42
- resp = resp_fn(resp, command)
39
+ """Downgrade a native-host response to the format the client expects."""
40
+ if not _COMPAT:
43
41
  return resp
42
+ cv = parse_version(client_version)
43
+ for version, _, resp_fn in reversed(_COMPAT):
44
+ if cv < parse_version(version) and resp_fn is not None:
45
+ resp = resp_fn(resp, command)
46
+ return resp
@@ -0,0 +1,108 @@
1
+ """SSH-style known-hosts pinning for browser-cli remote servers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from browser_cli.constants import CONFIG_DIR
10
+ from browser_cli.endpoints import _normalize_endpoint
11
+ from browser_cli.errors import BrowserNotConnected
12
+
13
+ KNOWN_HOSTS_PATH = CONFIG_DIR / "known_hosts.json"
14
+
15
+ def fingerprint(pubkey_hex: str) -> str:
16
+ """Return a compact SHA256 fingerprint for display."""
17
+ import base64
18
+ import hashlib
19
+
20
+ digest = hashlib.sha256(bytes.fromhex(pubkey_hex)).digest()
21
+ return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
22
+
23
+ def load_known_hosts(path: Path | None = None) -> dict[str, str]:
24
+ path = path or KNOWN_HOSTS_PATH
25
+ if not path.exists():
26
+ return {}
27
+ try:
28
+ data = json.loads(path.read_text(encoding="utf-8"))
29
+ except Exception:
30
+ return {}
31
+ if not isinstance(data, dict):
32
+ return {}
33
+ return {_normalize_endpoint(str(endpoint)): str(pubkey) for endpoint, pubkey in data.items() if isinstance(pubkey, str)}
34
+
35
+ def save_known_host(endpoint: str, pubkey_hex: str, path: Path | None = None) -> None:
36
+ path = path or KNOWN_HOSTS_PATH
37
+ known = load_known_hosts(path)
38
+ known[_normalize_endpoint(endpoint)] = pubkey_hex
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
41
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
42
+ file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
43
+
44
+ def remove_known_host(endpoint: str, path: Path | None = None) -> bool:
45
+ path = path or KNOWN_HOSTS_PATH
46
+ known = load_known_hosts(path)
47
+ normalized = _normalize_endpoint(endpoint)
48
+ if normalized not in known:
49
+ return False
50
+ del known[normalized]
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
53
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
54
+ file.write(json.dumps(known, indent=2, sort_keys=True) + "\n")
55
+ return True
56
+
57
+ def _is_loopback_endpoint(endpoint: str) -> bool:
58
+ host, sep, _port = endpoint.rpartition(":")
59
+ check = host if sep else endpoint
60
+ return check in {"127.0.0.1", "localhost", "::1"}
61
+
62
+ def verify_known_host(endpoint: str, challenge: dict | None) -> None:
63
+ """Verify and pin the server identity from a challenge frame.
64
+
65
+ First contact auto-adds the host when the process is interactive, mirroring
66
+ SSH's trust-on-first-use flow. Non-interactive clients must pin explicitly via
67
+ `browser-cli remote trust-host ENDPOINT`.
68
+ """
69
+ if not isinstance(challenge, dict):
70
+ return
71
+ pubkey = challenge.get("server_pubkey")
72
+ if not isinstance(pubkey, str) or not pubkey:
73
+ return
74
+
75
+ from browser_cli.auth.server_identity import verify_challenge_signature
76
+ if not verify_challenge_signature(challenge):
77
+ raise BrowserNotConnected("Remote server identity signature is invalid")
78
+
79
+ normalized = _normalize_endpoint(endpoint)
80
+ known = load_known_hosts()
81
+ expected = known.get(normalized)
82
+ if expected is None and _is_loopback_endpoint(endpoint):
83
+ return
84
+ if expected is None:
85
+ if not sys.stdin.isatty():
86
+ raise BrowserNotConnected(
87
+ f"Unknown remote server identity for {normalized} ({fingerprint(pubkey)}).\n"
88
+ f"Run: browser-cli remote trust-host {normalized}"
89
+ )
90
+ sys.stderr.write(
91
+ f"The authenticity of remote '{normalized}' can't be established.\n"
92
+ f"Server key fingerprint is {fingerprint(pubkey)}.\n"
93
+ "Trust this server and add it to known hosts? [y/N] "
94
+ )
95
+ answer = sys.stdin.readline().strip().lower()
96
+ if answer not in {"y", "yes"}:
97
+ raise BrowserNotConnected("Remote server identity was not trusted")
98
+ save_known_host(normalized, pubkey)
99
+ sys.stderr.write(f"Added {normalized} to browser-cli known hosts.\n")
100
+ return
101
+
102
+ if expected != pubkey:
103
+ raise BrowserNotConnected(
104
+ f"REMOTE SERVER IDENTITY CHANGED for {normalized}!\n"
105
+ f"Known: {fingerprint(expected)}\n"
106
+ f"Seen: {fingerprint(pubkey)}\n"
107
+ f"If this is expected, run: browser-cli remote untrust-host {normalized} && browser-cli remote trust-host {normalized}"
108
+ )
@@ -28,18 +28,39 @@ def is_valid_key_spec(value: str) -> bool:
28
28
  not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"})
29
29
  )
30
30
 
31
- def save_remote_key(endpoint: str, key_spec: str) -> None:
32
- """Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
33
- if not endpoint or not key_spec or not is_valid_key_spec(key_spec):
31
+ def save_remote(endpoint: str, key_spec: str | None = None) -> None:
32
+ """Persist a remote endpoint, optionally with a key spec."""
33
+ if not endpoint:
34
34
  return
35
+ normalized = _normalize_endpoint(endpoint)
35
36
  remotes = load_remotes()
36
- current = remotes.get(endpoint, {})
37
- current["key"] = key_spec
38
- remotes[endpoint] = current
37
+ current = remotes.get(normalized, {})
38
+ if key_spec:
39
+ if not is_valid_key_spec(key_spec):
40
+ return
41
+ current["key"] = key_spec
42
+ remotes[normalized] = current
39
43
  REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
40
44
  fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
41
45
  with os.fdopen(fd, "w", encoding="utf-8") as f:
42
- f.write(json.dumps(remotes, indent=2, sort_keys=True))
46
+ f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
47
+
48
+ def remove_remote(endpoint: str) -> bool:
49
+ """Remove a remembered remote endpoint. Returns True when it existed."""
50
+ normalized = _normalize_endpoint(endpoint)
51
+ remotes = load_remotes()
52
+ if normalized not in remotes:
53
+ return False
54
+ del remotes[normalized]
55
+ REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
56
+ fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
57
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
58
+ f.write(json.dumps(remotes, indent=2, sort_keys=True) + "\n")
59
+ return True
60
+
61
+ def save_remote_key(endpoint: str, key_spec: str) -> None:
62
+ """Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
63
+ save_remote(endpoint, key_spec)
43
64
 
44
65
  def key_for_remote(endpoint: str | None) -> str | None:
45
66
  if not endpoint:
@@ -28,6 +28,7 @@ from browser_cli.remote.socket import (
28
28
  split_endpoint as _split_endpoint,
29
29
  )
30
30
  from browser_cli.remote import pool as _pool
31
+ from browser_cli.remote.known_hosts import verify_known_host
31
32
 
32
33
  def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
33
34
  # Reuse an already-authenticated connection when one is idle for this endpoint.
@@ -51,7 +52,10 @@ def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_n
51
52
 
52
53
  sock = _connect_socket(endpoint)
53
54
  try:
54
- payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
55
+ challenge_raw = _recv_all(sock)
56
+ challenge, _nonce_hex = _parse_challenge(challenge_raw)
57
+ verify_known_host(endpoint, challenge)
58
+ payload_msg, pq_shared_secret = _with_challenge(challenge_raw, msg, private_key, build_auth)
55
59
  sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
56
60
  response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
57
61
  except BaseException:
@@ -69,6 +73,8 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
69
73
  reader, writer = await _open_async_connection(endpoint)
70
74
  try:
71
75
  challenge_raw = await _async_recv_all(reader)
76
+ challenge, _nonce_hex = _parse_challenge(challenge_raw)
77
+ verify_known_host(endpoint, challenge)
72
78
  warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
73
79
 
74
80
  async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
@@ -28,4 +28,8 @@ async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | No
28
28
  if pq_keypair is not None:
29
29
  pq_private_key, pq_public_key = pq_keypair
30
30
  challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
31
+ from browser_cli.auth.server_identity import load_or_create_server_identity, public_key_hex, sign_challenge
32
+ server_key = await asyncio.to_thread(load_or_create_server_identity)
33
+ challenge_msg["server_pubkey"] = public_key_hex(server_key)
34
+ challenge_msg["server_sig"] = sign_challenge(challenge_msg, server_key)
31
35
  return nonce, pq_private_key, challenge_msg
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "browser-cli",
4
- "version": "0.16.3",
4
+ "version": "0.16.4",
5
5
  "description": "Control your browser from the terminal or Python SDK",
6
6
  "browser_specific_settings": {
7
7
  "gecko": {
@@ -4,6 +4,26 @@ import { CommandGroup } from '../classes/CommandGroup';
4
4
  import type { CommandEntry } from '../classes/CommandGroup';
5
5
  import type { TabIdArgs, TabsActiveInWindowArgs, TabsPatternArgs, TabsQueryArgs, TabsWatchUrlArgs } from '../types';
6
6
 
7
+ /** Convert a shell-style glob (`*` = any run, `?` = any single char) to an
8
+ * unanchored RegExp. Every other character is matched literally. Unanchored so
9
+ * `twitch.tv/*` matches anywhere inside `https://www.twitch.tv/foo`. */
10
+ function globToRegExp(glob: string): RegExp {
11
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
12
+ return new RegExp(escaped.replace(/\*/g, '.*').replace(/\?/g, '.'));
13
+ }
14
+
15
+ /**
16
+ * Match a tab URL against a pattern. Backward-compatible: a pattern with no
17
+ * glob metacharacters is a plain case-sensitive substring match (the historic
18
+ * behavior); a pattern containing `*` or `?` is treated as a glob, so
19
+ * `twitch.tv/*` matches every Twitch tab.
20
+ */
21
+ export function urlMatchesPattern(url: string | undefined, pattern: string): boolean {
22
+ if (!url || !pattern) return false;
23
+ if (/[*?]/.test(pattern)) return globToRegExp(pattern).test(url);
24
+ return url.includes(pattern);
25
+ }
26
+
7
27
  export class TabsQueryCommands extends CommandGroup {
8
28
  readonly namespace = "tabs";
9
29
  readonly commands: Record<string, CommandEntry> = {
@@ -50,12 +70,12 @@ export class TabsQueryCommands extends CommandGroup {
50
70
 
51
71
  private async tabsFilter({ pattern }: TabsPatternArgs) {
52
72
  const all = await api.tabs.query({});
53
- return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
73
+ return all.filter(t => urlMatchesPattern(t.url, pattern)).map(tabInfo);
54
74
  }
55
75
 
56
76
  private async tabsCount({ pattern }: TabsPatternArgs) {
57
77
  const all = await api.tabs.query({});
58
- if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
78
+ if (pattern) return all.filter(t => urlMatchesPattern(t.url, pattern)).length;
59
79
  return all.length;
60
80
  }
61
81