tabwright 1.0.0

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 (599) hide show
  1. package/LICENSE +21 -0
  2. package/bin.js +4 -0
  3. package/dist/a11y-client.js +204 -0
  4. package/dist/agent-skills/tabwright/SKILL.md +119 -0
  5. package/dist/aria-snapshot.d.ts +262 -0
  6. package/dist/aria-snapshot.d.ts.map +1 -0
  7. package/dist/aria-snapshot.js +1256 -0
  8. package/dist/aria-snapshot.js.map +1 -0
  9. package/dist/aria-snapshot.test.d.ts +2 -0
  10. package/dist/aria-snapshot.test.d.ts.map +1 -0
  11. package/dist/aria-snapshot.test.js +270 -0
  12. package/dist/aria-snapshot.test.js.map +1 -0
  13. package/dist/aria-snapshot.unit.test.d.ts +2 -0
  14. package/dist/aria-snapshot.unit.test.d.ts.map +1 -0
  15. package/dist/aria-snapshot.unit.test.js +542 -0
  16. package/dist/aria-snapshot.unit.test.js.map +1 -0
  17. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  18. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  19. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  20. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  21. package/dist/bippy.js +894 -0
  22. package/dist/browser-config.d.ts +13 -0
  23. package/dist/browser-config.d.ts.map +1 -0
  24. package/dist/browser-config.js +126 -0
  25. package/dist/browser-config.js.map +1 -0
  26. package/dist/browser-install.d.ts +16 -0
  27. package/dist/browser-install.d.ts.map +1 -0
  28. package/dist/browser-install.js +238 -0
  29. package/dist/browser-install.js.map +1 -0
  30. package/dist/browser-launch.d.ts +17 -0
  31. package/dist/browser-launch.d.ts.map +1 -0
  32. package/dist/browser-launch.js +44 -0
  33. package/dist/browser-launch.js.map +1 -0
  34. package/dist/builtin-capabilities.d.ts +28 -0
  35. package/dist/builtin-capabilities.d.ts.map +1 -0
  36. package/dist/builtin-capabilities.js +2673 -0
  37. package/dist/builtin-capabilities.js.map +1 -0
  38. package/dist/capability-agent-skill.d.ts +60 -0
  39. package/dist/capability-agent-skill.d.ts.map +1 -0
  40. package/dist/capability-agent-skill.js +225 -0
  41. package/dist/capability-agent-skill.js.map +1 -0
  42. package/dist/capability-auth-state.d.ts +34 -0
  43. package/dist/capability-auth-state.d.ts.map +1 -0
  44. package/dist/capability-auth-state.js +157 -0
  45. package/dist/capability-auth-state.js.map +1 -0
  46. package/dist/capability-auth.d.ts +36 -0
  47. package/dist/capability-auth.d.ts.map +1 -0
  48. package/dist/capability-auth.js +198 -0
  49. package/dist/capability-auth.js.map +1 -0
  50. package/dist/capability-cli-confirmation.test.d.ts +2 -0
  51. package/dist/capability-cli-confirmation.test.d.ts.map +1 -0
  52. package/dist/capability-cli-confirmation.test.js +79 -0
  53. package/dist/capability-cli-confirmation.test.js.map +1 -0
  54. package/dist/capability-options.d.ts +20 -0
  55. package/dist/capability-options.d.ts.map +1 -0
  56. package/dist/capability-options.js +31 -0
  57. package/dist/capability-options.js.map +1 -0
  58. package/dist/capability-options.test.d.ts +2 -0
  59. package/dist/capability-options.test.d.ts.map +1 -0
  60. package/dist/capability-options.test.js +81 -0
  61. package/dist/capability-options.test.js.map +1 -0
  62. package/dist/capability-package.d.ts +27 -0
  63. package/dist/capability-package.d.ts.map +1 -0
  64. package/dist/capability-package.js +444 -0
  65. package/dist/capability-package.js.map +1 -0
  66. package/dist/capability-package.test.d.ts +2 -0
  67. package/dist/capability-package.test.d.ts.map +1 -0
  68. package/dist/capability-package.test.js +179 -0
  69. package/dist/capability-package.test.js.map +1 -0
  70. package/dist/capability-registry.d.ts +262 -0
  71. package/dist/capability-registry.d.ts.map +1 -0
  72. package/dist/capability-registry.js +931 -0
  73. package/dist/capability-registry.js.map +1 -0
  74. package/dist/capability-registry.test.d.ts +2 -0
  75. package/dist/capability-registry.test.d.ts.map +1 -0
  76. package/dist/capability-registry.test.js +1683 -0
  77. package/dist/capability-registry.test.js.map +1 -0
  78. package/dist/capability-runner.d.ts +99 -0
  79. package/dist/capability-runner.d.ts.map +1 -0
  80. package/dist/capability-runner.js +608 -0
  81. package/dist/capability-runner.js.map +1 -0
  82. package/dist/capability-search-relevance.test.d.ts +2 -0
  83. package/dist/capability-search-relevance.test.d.ts.map +1 -0
  84. package/dist/capability-search-relevance.test.js +67 -0
  85. package/dist/capability-search-relevance.test.js.map +1 -0
  86. package/dist/capability-studio.d.ts +11 -0
  87. package/dist/capability-studio.d.ts.map +1 -0
  88. package/dist/capability-studio.js +593 -0
  89. package/dist/capability-studio.js.map +1 -0
  90. package/dist/capability-studio.test.d.ts +2 -0
  91. package/dist/capability-studio.test.d.ts.map +1 -0
  92. package/dist/capability-studio.test.js +41 -0
  93. package/dist/capability-studio.test.js.map +1 -0
  94. package/dist/cdp-log.d.ts +19 -0
  95. package/dist/cdp-log.d.ts.map +1 -0
  96. package/dist/cdp-log.js +83 -0
  97. package/dist/cdp-log.js.map +1 -0
  98. package/dist/cdp-log.test.d.ts +2 -0
  99. package/dist/cdp-log.test.d.ts.map +1 -0
  100. package/dist/cdp-log.test.js +109 -0
  101. package/dist/cdp-log.test.js.map +1 -0
  102. package/dist/cdp-relay.d.ts +19 -0
  103. package/dist/cdp-relay.d.ts.map +1 -0
  104. package/dist/cdp-relay.js +2375 -0
  105. package/dist/cdp-relay.js.map +1 -0
  106. package/dist/cdp-session.d.ts +37 -0
  107. package/dist/cdp-session.d.ts.map +1 -0
  108. package/dist/cdp-session.js +36 -0
  109. package/dist/cdp-session.js.map +1 -0
  110. package/dist/cdp-types.d.ts +65 -0
  111. package/dist/cdp-types.d.ts.map +1 -0
  112. package/dist/cdp-types.js +91 -0
  113. package/dist/cdp-types.js.map +1 -0
  114. package/dist/channel-owner-inspect.test.d.ts +2 -0
  115. package/dist/channel-owner-inspect.test.d.ts.map +1 -0
  116. package/dist/channel-owner-inspect.test.js +75 -0
  117. package/dist/channel-owner-inspect.test.js.map +1 -0
  118. package/dist/chrome-discovery.d.ts +65 -0
  119. package/dist/chrome-discovery.d.ts.map +1 -0
  120. package/dist/chrome-discovery.js +173 -0
  121. package/dist/chrome-discovery.js.map +1 -0
  122. package/dist/chrome-discovery.test.d.ts +2 -0
  123. package/dist/chrome-discovery.test.d.ts.map +1 -0
  124. package/dist/chrome-discovery.test.js +41 -0
  125. package/dist/chrome-discovery.test.js.map +1 -0
  126. package/dist/clean-html.d.ts +11 -0
  127. package/dist/clean-html.d.ts.map +1 -0
  128. package/dist/clean-html.js +106 -0
  129. package/dist/clean-html.js.map +1 -0
  130. package/dist/cli-help.test.d.ts +2 -0
  131. package/dist/cli-help.test.d.ts.map +1 -0
  132. package/dist/cli-help.test.js +98 -0
  133. package/dist/cli-help.test.js.map +1 -0
  134. package/dist/cli.d.ts +3 -0
  135. package/dist/cli.d.ts.map +1 -0
  136. package/dist/cli.js +2709 -0
  137. package/dist/cli.js.map +1 -0
  138. package/dist/cloud-client.d.ts +56 -0
  139. package/dist/cloud-client.d.ts.map +1 -0
  140. package/dist/cloud-client.js +127 -0
  141. package/dist/cloud-client.js.map +1 -0
  142. package/dist/create-logger.d.ts +9 -0
  143. package/dist/create-logger.d.ts.map +1 -0
  144. package/dist/create-logger.js +27 -0
  145. package/dist/create-logger.js.map +1 -0
  146. package/dist/debugger-api.md +458 -0
  147. package/dist/debugger-examples-types.d.ts +24 -0
  148. package/dist/debugger-examples-types.d.ts.map +1 -0
  149. package/dist/debugger-examples-types.js +2 -0
  150. package/dist/debugger-examples-types.js.map +1 -0
  151. package/dist/debugger-examples.d.ts +6 -0
  152. package/dist/debugger-examples.d.ts.map +1 -0
  153. package/dist/debugger-examples.js +53 -0
  154. package/dist/debugger-examples.js.map +1 -0
  155. package/dist/debugger.d.ts +381 -0
  156. package/dist/debugger.d.ts.map +1 -0
  157. package/dist/debugger.js +630 -0
  158. package/dist/debugger.js.map +1 -0
  159. package/dist/diff-utils.d.ts +20 -0
  160. package/dist/diff-utils.d.ts.map +1 -0
  161. package/dist/diff-utils.js +50 -0
  162. package/dist/diff-utils.js.map +1 -0
  163. package/dist/diff-utils.test.d.ts +2 -0
  164. package/dist/diff-utils.test.d.ts.map +1 -0
  165. package/dist/diff-utils.test.js +137 -0
  166. package/dist/diff-utils.test.js.map +1 -0
  167. package/dist/doctor.d.ts +44 -0
  168. package/dist/doctor.d.ts.map +1 -0
  169. package/dist/doctor.js +246 -0
  170. package/dist/doctor.js.map +1 -0
  171. package/dist/doctor.test.d.ts +2 -0
  172. package/dist/doctor.test.d.ts.map +1 -0
  173. package/dist/doctor.test.js +208 -0
  174. package/dist/doctor.test.js.map +1 -0
  175. package/dist/editor-api.md +374 -0
  176. package/dist/editor-examples.d.ts +11 -0
  177. package/dist/editor-examples.d.ts.map +1 -0
  178. package/dist/editor-examples.js +124 -0
  179. package/dist/editor-examples.js.map +1 -0
  180. package/dist/editor.d.ts +203 -0
  181. package/dist/editor.d.ts.map +1 -0
  182. package/dist/editor.js +363 -0
  183. package/dist/editor.js.map +1 -0
  184. package/dist/env-compat.d.ts +2 -0
  185. package/dist/env-compat.d.ts.map +1 -0
  186. package/dist/env-compat.js +27 -0
  187. package/dist/env-compat.js.map +1 -0
  188. package/dist/executor.d.ts +229 -0
  189. package/dist/executor.d.ts.map +1 -0
  190. package/dist/executor.js +1465 -0
  191. package/dist/executor.js.map +1 -0
  192. package/dist/executor.unit.test.d.ts +2 -0
  193. package/dist/executor.unit.test.d.ts.map +1 -0
  194. package/dist/executor.unit.test.js +143 -0
  195. package/dist/executor.unit.test.js.map +1 -0
  196. package/dist/extension/_locales/en/messages.json +278 -0
  197. package/dist/extension/_locales/zh_CN/messages.json +256 -0
  198. package/dist/extension/assets/options-CYH96Lcx.css +1349 -0
  199. package/dist/extension/background.js +3994 -0
  200. package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
  201. package/dist/extension/icons/icon-black-128.png +0 -0
  202. package/dist/extension/icons/icon-black-16.png +0 -0
  203. package/dist/extension/icons/icon-black-32.png +0 -0
  204. package/dist/extension/icons/icon-black-48.png +0 -0
  205. package/dist/extension/icons/icon-gray-128.png +0 -0
  206. package/dist/extension/icons/icon-gray-16.png +0 -0
  207. package/dist/extension/icons/icon-gray-32.png +0 -0
  208. package/dist/extension/icons/icon-gray-48.png +0 -0
  209. package/dist/extension/icons/icon-green-128.png +0 -0
  210. package/dist/extension/icons/icon-green-16.png +0 -0
  211. package/dist/extension/icons/icon-green-32.png +0 -0
  212. package/dist/extension/icons/icon-green-48.png +0 -0
  213. package/dist/extension/icons/tabwright-icon-black.png +0 -0
  214. package/dist/extension/icons/tabwright-icon-gray-disabled.png +0 -0
  215. package/dist/extension/icons/tabwright-icon-gray-disabled.svg +1 -0
  216. package/dist/extension/manifest.json +54 -0
  217. package/dist/extension/options.js +16145 -0
  218. package/dist/extension/rrweb-recorder.js +12952 -0
  219. package/dist/extension/src/options.html +165 -0
  220. package/dist/extension/src/prism-bash.min.js +1 -0
  221. package/dist/extension/src/prism.min.js +1 -0
  222. package/dist/extension/src/welcome.html +396 -0
  223. package/dist/extension-connection.test.d.ts +2 -0
  224. package/dist/extension-connection.test.d.ts.map +1 -0
  225. package/dist/extension-connection.test.js +708 -0
  226. package/dist/extension-connection.test.js.map +1 -0
  227. package/dist/extension-icon-warning.test.d.ts +2 -0
  228. package/dist/extension-icon-warning.test.d.ts.map +1 -0
  229. package/dist/extension-icon-warning.test.js +33 -0
  230. package/dist/extension-icon-warning.test.js.map +1 -0
  231. package/dist/ghost-browser.d.ts +62 -0
  232. package/dist/ghost-browser.d.ts.map +1 -0
  233. package/dist/ghost-browser.js +107 -0
  234. package/dist/ghost-browser.js.map +1 -0
  235. package/dist/ghost-cursor-client.js +374 -0
  236. package/dist/ghost-cursor-controller.d.ts +46 -0
  237. package/dist/ghost-cursor-controller.d.ts.map +1 -0
  238. package/dist/ghost-cursor-controller.js +98 -0
  239. package/dist/ghost-cursor-controller.js.map +1 -0
  240. package/dist/ghost-cursor.d.ts +27 -0
  241. package/dist/ghost-cursor.d.ts.map +1 -0
  242. package/dist/ghost-cursor.js +79 -0
  243. package/dist/ghost-cursor.js.map +1 -0
  244. package/dist/htmlrewrite.d.ts +8 -0
  245. package/dist/htmlrewrite.d.ts.map +1 -0
  246. package/dist/htmlrewrite.js +339 -0
  247. package/dist/htmlrewrite.js.map +1 -0
  248. package/dist/htmlrewrite.test.d.ts +2 -0
  249. package/dist/htmlrewrite.test.d.ts.map +1 -0
  250. package/dist/htmlrewrite.test.js +7975 -0
  251. package/dist/htmlrewrite.test.js.map +1 -0
  252. package/dist/index.d.ts +20 -0
  253. package/dist/index.d.ts.map +1 -0
  254. package/dist/index.js +11 -0
  255. package/dist/index.js.map +1 -0
  256. package/dist/kill-port.d.ts +29 -0
  257. package/dist/kill-port.d.ts.map +1 -0
  258. package/dist/kill-port.js +190 -0
  259. package/dist/kill-port.js.map +1 -0
  260. package/dist/kitty-graphics.d.ts +22 -0
  261. package/dist/kitty-graphics.d.ts.map +1 -0
  262. package/dist/kitty-graphics.js +71 -0
  263. package/dist/kitty-graphics.js.map +1 -0
  264. package/dist/kitty-graphics.test.d.ts +2 -0
  265. package/dist/kitty-graphics.test.d.ts.map +1 -0
  266. package/dist/kitty-graphics.test.js +125 -0
  267. package/dist/kitty-graphics.test.js.map +1 -0
  268. package/dist/locator-selector.test.d.ts +2 -0
  269. package/dist/locator-selector.test.d.ts.map +1 -0
  270. package/dist/locator-selector.test.js +98 -0
  271. package/dist/locator-selector.test.js.map +1 -0
  272. package/dist/mcp-client.d.ts +20 -0
  273. package/dist/mcp-client.d.ts.map +1 -0
  274. package/dist/mcp-client.js +56 -0
  275. package/dist/mcp-client.js.map +1 -0
  276. package/dist/mcp.d.ts +6 -0
  277. package/dist/mcp.d.ts.map +1 -0
  278. package/dist/mcp.js +511 -0
  279. package/dist/mcp.js.map +1 -0
  280. package/dist/on-mouse-action.test.d.ts +2 -0
  281. package/dist/on-mouse-action.test.d.ts.map +1 -0
  282. package/dist/on-mouse-action.test.js +180 -0
  283. package/dist/on-mouse-action.test.js.map +1 -0
  284. package/dist/options-page.test.d.ts +2 -0
  285. package/dist/options-page.test.d.ts.map +1 -0
  286. package/dist/options-page.test.js +773 -0
  287. package/dist/options-page.test.js.map +1 -0
  288. package/dist/package-paths.d.ts +4 -0
  289. package/dist/package-paths.d.ts.map +1 -0
  290. package/dist/package-paths.js +30 -0
  291. package/dist/package-paths.js.map +1 -0
  292. package/dist/page-markdown.d.ts +40 -0
  293. package/dist/page-markdown.d.ts.map +1 -0
  294. package/dist/page-markdown.js +181 -0
  295. package/dist/page-markdown.js.map +1 -0
  296. package/dist/performance-examples.d.ts +5 -0
  297. package/dist/performance-examples.d.ts.map +1 -0
  298. package/dist/performance-examples.js +112 -0
  299. package/dist/performance-examples.js.map +1 -0
  300. package/dist/performance-profiling.md +417 -0
  301. package/dist/playwright-import.d.ts +19 -0
  302. package/dist/playwright-import.d.ts.map +1 -0
  303. package/dist/playwright-import.js +40 -0
  304. package/dist/playwright-import.js.map +1 -0
  305. package/dist/popup-relocation.test.d.ts +7 -0
  306. package/dist/popup-relocation.test.d.ts.map +1 -0
  307. package/dist/popup-relocation.test.js +139 -0
  308. package/dist/popup-relocation.test.js.map +1 -0
  309. package/dist/product-paths.d.ts +9 -0
  310. package/dist/product-paths.d.ts.map +1 -0
  311. package/dist/product-paths.js +24 -0
  312. package/dist/product-paths.js.map +1 -0
  313. package/dist/product-paths.test.d.ts +2 -0
  314. package/dist/product-paths.test.d.ts.map +1 -0
  315. package/dist/product-paths.test.js +39 -0
  316. package/dist/product-paths.test.js.map +1 -0
  317. package/dist/prompt.md +1004 -0
  318. package/dist/protocol.d.ts +248 -0
  319. package/dist/protocol.d.ts.map +1 -0
  320. package/dist/protocol.js +59 -0
  321. package/dist/protocol.js.map +1 -0
  322. package/dist/protocol.test.d.ts +2 -0
  323. package/dist/protocol.test.d.ts.map +1 -0
  324. package/dist/protocol.test.js +31 -0
  325. package/dist/protocol.test.js.map +1 -0
  326. package/dist/react-source.d.ts +57 -0
  327. package/dist/react-source.d.ts.map +1 -0
  328. package/dist/react-source.js +254 -0
  329. package/dist/react-source.js.map +1 -0
  330. package/dist/readability.js +1674 -0
  331. package/dist/relay-client.d.ts +104 -0
  332. package/dist/relay-client.d.ts.map +1 -0
  333. package/dist/relay-client.js +426 -0
  334. package/dist/relay-client.js.map +1 -0
  335. package/dist/relay-client.test.d.ts +2 -0
  336. package/dist/relay-client.test.d.ts.map +1 -0
  337. package/dist/relay-client.test.js +305 -0
  338. package/dist/relay-client.test.js.map +1 -0
  339. package/dist/relay-core.test.d.ts +2 -0
  340. package/dist/relay-core.test.d.ts.map +1 -0
  341. package/dist/relay-core.test.js +1427 -0
  342. package/dist/relay-core.test.js.map +1 -0
  343. package/dist/relay-navigation.test.d.ts +2 -0
  344. package/dist/relay-navigation.test.d.ts.map +1 -0
  345. package/dist/relay-navigation.test.js +661 -0
  346. package/dist/relay-navigation.test.js.map +1 -0
  347. package/dist/relay-session.test.d.ts +2 -0
  348. package/dist/relay-session.test.d.ts.map +1 -0
  349. package/dist/relay-session.test.js +1121 -0
  350. package/dist/relay-session.test.js.map +1 -0
  351. package/dist/relay-state.d.ts +178 -0
  352. package/dist/relay-state.d.ts.map +1 -0
  353. package/dist/relay-state.js +346 -0
  354. package/dist/relay-state.js.map +1 -0
  355. package/dist/relay-state.test.d.ts +2 -0
  356. package/dist/relay-state.test.d.ts.map +1 -0
  357. package/dist/relay-state.test.js +594 -0
  358. package/dist/relay-state.test.js.map +1 -0
  359. package/dist/replay-ai-index.d.ts +99 -0
  360. package/dist/replay-ai-index.d.ts.map +1 -0
  361. package/dist/replay-ai-index.js +677 -0
  362. package/dist/replay-ai-index.js.map +1 -0
  363. package/dist/replay-ai-index.test.d.ts +2 -0
  364. package/dist/replay-ai-index.test.d.ts.map +1 -0
  365. package/dist/replay-ai-index.test.js +255 -0
  366. package/dist/replay-ai-index.test.js.map +1 -0
  367. package/dist/replay-cli.test.d.ts +2 -0
  368. package/dist/replay-cli.test.d.ts.map +1 -0
  369. package/dist/replay-cli.test.js +275 -0
  370. package/dist/replay-cli.test.js.map +1 -0
  371. package/dist/replay-eval.d.ts +44 -0
  372. package/dist/replay-eval.d.ts.map +1 -0
  373. package/dist/replay-eval.js +902 -0
  374. package/dist/replay-eval.js.map +1 -0
  375. package/dist/replay-handoff.d.ts +29 -0
  376. package/dist/replay-handoff.d.ts.map +1 -0
  377. package/dist/replay-handoff.js +83 -0
  378. package/dist/replay-handoff.js.map +1 -0
  379. package/dist/replay-handoff.test.d.ts +2 -0
  380. package/dist/replay-handoff.test.d.ts.map +1 -0
  381. package/dist/replay-handoff.test.js +123 -0
  382. package/dist/replay-handoff.test.js.map +1 -0
  383. package/dist/replay-workflow-compiler.d.ts +44 -0
  384. package/dist/replay-workflow-compiler.d.ts.map +1 -0
  385. package/dist/replay-workflow-compiler.js +601 -0
  386. package/dist/replay-workflow-compiler.js.map +1 -0
  387. package/dist/replay-workflow-compiler.test.d.ts +2 -0
  388. package/dist/replay-workflow-compiler.test.d.ts.map +1 -0
  389. package/dist/replay-workflow-compiler.test.js +223 -0
  390. package/dist/replay-workflow-compiler.test.js.map +1 -0
  391. package/dist/rrweb-recording-relay.d.ts +56 -0
  392. package/dist/rrweb-recording-relay.d.ts.map +1 -0
  393. package/dist/rrweb-recording-relay.js +299 -0
  394. package/dist/rrweb-recording-relay.js.map +1 -0
  395. package/dist/rrweb-recording-relay.test.d.ts +2 -0
  396. package/dist/rrweb-recording-relay.test.d.ts.map +1 -0
  397. package/dist/rrweb-recording-relay.test.js +85 -0
  398. package/dist/rrweb-recording-relay.test.js.map +1 -0
  399. package/dist/rrweb-recording.d.ts +104 -0
  400. package/dist/rrweb-recording.d.ts.map +1 -0
  401. package/dist/rrweb-recording.js +180 -0
  402. package/dist/rrweb-recording.js.map +1 -0
  403. package/dist/scoped-fs.d.ts +95 -0
  404. package/dist/scoped-fs.d.ts.map +1 -0
  405. package/dist/scoped-fs.js +358 -0
  406. package/dist/scoped-fs.js.map +1 -0
  407. package/dist/scoped-fs.test.d.ts +5 -0
  408. package/dist/scoped-fs.test.d.ts.map +1 -0
  409. package/dist/scoped-fs.test.js +73 -0
  410. package/dist/scoped-fs.test.js.map +1 -0
  411. package/dist/selector-generator.js +7378 -0
  412. package/dist/skill-stub.test.d.ts +2 -0
  413. package/dist/skill-stub.test.d.ts.map +1 -0
  414. package/dist/skill-stub.test.js +22 -0
  415. package/dist/skill-stub.test.js.map +1 -0
  416. package/dist/snapshot-tools.test.d.ts +2 -0
  417. package/dist/snapshot-tools.test.d.ts.map +1 -0
  418. package/dist/snapshot-tools.test.js +856 -0
  419. package/dist/snapshot-tools.test.js.map +1 -0
  420. package/dist/start-relay-server.d.ts +6 -0
  421. package/dist/start-relay-server.d.ts.map +1 -0
  422. package/dist/start-relay-server.js +57 -0
  423. package/dist/start-relay-server.js.map +1 -0
  424. package/dist/styles-api.md +124 -0
  425. package/dist/styles-examples.d.ts +8 -0
  426. package/dist/styles-examples.d.ts.map +1 -0
  427. package/dist/styles-examples.js +64 -0
  428. package/dist/styles-examples.js.map +1 -0
  429. package/dist/styles.d.ts +27 -0
  430. package/dist/styles.d.ts.map +1 -0
  431. package/dist/styles.js +231 -0
  432. package/dist/styles.js.map +1 -0
  433. package/dist/tabwright-agent-skill.d.ts +30 -0
  434. package/dist/tabwright-agent-skill.d.ts.map +1 -0
  435. package/dist/tabwright-agent-skill.js +74 -0
  436. package/dist/tabwright-agent-skill.js.map +1 -0
  437. package/dist/tabwright-agent-skill.test.d.ts +2 -0
  438. package/dist/tabwright-agent-skill.test.d.ts.map +1 -0
  439. package/dist/tabwright-agent-skill.test.js +51 -0
  440. package/dist/tabwright-agent-skill.test.js.map +1 -0
  441. package/dist/test-declarations.d.ts +13 -0
  442. package/dist/test-declarations.d.ts.map +1 -0
  443. package/dist/test-declarations.js +2 -0
  444. package/dist/test-declarations.js.map +1 -0
  445. package/dist/test-utils.d.ts +69 -0
  446. package/dist/test-utils.d.ts.map +1 -0
  447. package/dist/test-utils.js +448 -0
  448. package/dist/test-utils.js.map +1 -0
  449. package/dist/test-utils.test.d.ts +2 -0
  450. package/dist/test-utils.test.d.ts.map +1 -0
  451. package/dist/test-utils.test.js +55 -0
  452. package/dist/test-utils.test.js.map +1 -0
  453. package/dist/utils.d.ts +26 -0
  454. package/dist/utils.d.ts.map +1 -0
  455. package/dist/utils.js +60 -0
  456. package/dist/utils.js.map +1 -0
  457. package/dist/wait-for-page-load.d.ts +16 -0
  458. package/dist/wait-for-page-load.d.ts.map +1 -0
  459. package/dist/wait-for-page-load.js +146 -0
  460. package/dist/wait-for-page-load.js.map +1 -0
  461. package/dist/workflow-capability.d.ts +72 -0
  462. package/dist/workflow-capability.d.ts.map +1 -0
  463. package/dist/workflow-capability.js +316 -0
  464. package/dist/workflow-capability.js.map +1 -0
  465. package/dist/workflow-script-flow.test.d.ts +2 -0
  466. package/dist/workflow-script-flow.test.d.ts.map +1 -0
  467. package/dist/workflow-script-flow.test.js +285 -0
  468. package/dist/workflow-script-flow.test.js.map +1 -0
  469. package/package.json +80 -0
  470. package/src/__snapshots__/x.com.processed.html +1003 -0
  471. package/src/__snapshots__/x.com.processed.withStyles.html +2720 -0
  472. package/src/a11y-client.ts +241 -0
  473. package/src/agent-skills/conan-config/SKILL.md +141 -0
  474. package/src/agent-skills/conan-config/agents/openai.yaml +4 -0
  475. package/src/aria-snapshot.test.ts +311 -0
  476. package/src/aria-snapshot.ts +1681 -0
  477. package/src/aria-snapshot.unit.test.ts +615 -0
  478. package/src/aria-snapshots/github-interactive.txt +193 -0
  479. package/src/aria-snapshots/github-raw.txt +309 -0
  480. package/src/aria-snapshots/hackernews-interactive.txt +477 -0
  481. package/src/aria-snapshots/hackernews-raw.txt +428 -0
  482. package/src/aria-snapshots/prosemirror-interactive.txt +66 -0
  483. package/src/aria-snapshots/prosemirror-raw.txt +145 -0
  484. package/src/assets/aria-labels-hacker-news.png +0 -0
  485. package/src/assets/aria-labels-old-reddit.png +0 -0
  486. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  487. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  488. package/src/assets/x.com.html +32946 -0
  489. package/src/browser-config.ts +179 -0
  490. package/src/browser-install.ts +284 -0
  491. package/src/browser-launch.ts +75 -0
  492. package/src/builtin-capabilities.ts +2765 -0
  493. package/src/capability-agent-skill.ts +305 -0
  494. package/src/capability-auth-state.ts +197 -0
  495. package/src/capability-auth.ts +249 -0
  496. package/src/capability-cli-confirmation.test.ts +90 -0
  497. package/src/capability-options.test.ts +89 -0
  498. package/src/capability-options.ts +57 -0
  499. package/src/capability-package.test.ts +204 -0
  500. package/src/capability-package.ts +555 -0
  501. package/src/capability-registry.test.ts +1905 -0
  502. package/src/capability-registry.ts +1234 -0
  503. package/src/capability-runner.ts +801 -0
  504. package/src/capability-search-relevance.test.ts +73 -0
  505. package/src/capability-studio.test.ts +45 -0
  506. package/src/capability-studio.ts +641 -0
  507. package/src/cdp-log.test.ts +131 -0
  508. package/src/cdp-log.ts +112 -0
  509. package/src/cdp-relay.ts +2944 -0
  510. package/src/cdp-session.ts +77 -0
  511. package/src/cdp-types.ts +158 -0
  512. package/src/channel-owner-inspect.test.ts +94 -0
  513. package/src/chrome-discovery.test.ts +50 -0
  514. package/src/chrome-discovery.ts +220 -0
  515. package/src/clean-html.ts +141 -0
  516. package/src/cli-help.test.ts +123 -0
  517. package/src/cli.ts +3181 -0
  518. package/src/cloud-client.ts +179 -0
  519. package/src/create-logger.ts +38 -0
  520. package/src/debugger-examples-types.ts +16 -0
  521. package/src/debugger-examples.ts +66 -0
  522. package/src/debugger.ts +708 -0
  523. package/src/diff-utils.test.ts +152 -0
  524. package/src/diff-utils.ts +70 -0
  525. package/src/doctor.test.ts +244 -0
  526. package/src/doctor.ts +321 -0
  527. package/src/editor-examples.ts +158 -0
  528. package/src/editor.ts +426 -0
  529. package/src/env-compat.ts +28 -0
  530. package/src/executor.ts +1824 -0
  531. package/src/executor.unit.test.ts +168 -0
  532. package/src/extension-connection.test.ts +855 -0
  533. package/src/extension-icon-warning.test.ts +45 -0
  534. package/src/ghost-browser.ts +149 -0
  535. package/src/ghost-cursor-client.ts +494 -0
  536. package/src/ghost-cursor-controller.ts +129 -0
  537. package/src/ghost-cursor.ts +123 -0
  538. package/src/htmlrewrite.test.ts +8008 -0
  539. package/src/htmlrewrite.ts +390 -0
  540. package/src/index.ts +53 -0
  541. package/src/kill-port.ts +219 -0
  542. package/src/kitty-graphics.test.ts +139 -0
  543. package/src/kitty-graphics.ts +79 -0
  544. package/src/locator-selector.test.ts +119 -0
  545. package/src/mcp-client.ts +78 -0
  546. package/src/mcp.ts +629 -0
  547. package/src/on-mouse-action.test.ts +226 -0
  548. package/src/options-page.test.ts +839 -0
  549. package/src/package-paths.ts +38 -0
  550. package/src/page-markdown.ts +240 -0
  551. package/src/performance-examples.ts +186 -0
  552. package/src/playwright-import.ts +59 -0
  553. package/src/popup-relocation.test.ts +163 -0
  554. package/src/product-paths.test.ts +47 -0
  555. package/src/product-paths.ts +28 -0
  556. package/src/protocol.test.ts +52 -0
  557. package/src/protocol.ts +351 -0
  558. package/src/react-source.ts +379 -0
  559. package/src/relay-client.test.ts +375 -0
  560. package/src/relay-client.ts +572 -0
  561. package/src/relay-core.test.ts +1603 -0
  562. package/src/relay-navigation.test.ts +790 -0
  563. package/src/relay-session.test.ts +1375 -0
  564. package/src/relay-state.test.ts +729 -0
  565. package/src/relay-state.ts +561 -0
  566. package/src/replay-ai-index.test.ts +278 -0
  567. package/src/replay-ai-index.ts +872 -0
  568. package/src/replay-cli.test.ts +325 -0
  569. package/src/replay-eval.ts +1039 -0
  570. package/src/replay-handoff.test.ts +163 -0
  571. package/src/replay-handoff.ts +109 -0
  572. package/src/replay-workflow-compiler.test.ts +250 -0
  573. package/src/replay-workflow-compiler.ts +700 -0
  574. package/src/resource.md +409 -0
  575. package/src/rrweb-recording-relay.test.ts +99 -0
  576. package/src/rrweb-recording-relay.ts +400 -0
  577. package/src/rrweb-recording.ts +322 -0
  578. package/src/scoped-fs.test.ts +80 -0
  579. package/src/scoped-fs.ts +420 -0
  580. package/src/skill-stub.test.ts +24 -0
  581. package/src/skill.md +1413 -0
  582. package/src/snapshot-tools.test.ts +1017 -0
  583. package/src/snapshots/hacker-news-accessibility-full.md +81 -0
  584. package/src/snapshots/hacker-news-accessibility-interactive.md +43 -0
  585. package/src/snapshots/page-markdown-output.txt +16 -0
  586. package/src/snapshots/shadcn-ui-accessibility-full.md +201 -0
  587. package/src/snapshots/shadcn-ui-accessibility-interactive.md +109 -0
  588. package/src/start-relay-server.ts +69 -0
  589. package/src/styles-examples.ts +84 -0
  590. package/src/styles.ts +343 -0
  591. package/src/tabwright-agent-skill.test.ts +61 -0
  592. package/src/tabwright-agent-skill.ts +105 -0
  593. package/src/test-declarations.ts +13 -0
  594. package/src/test-utils.test.ts +70 -0
  595. package/src/test-utils.ts +566 -0
  596. package/src/utils.ts +78 -0
  597. package/src/wait-for-page-load.ts +198 -0
  598. package/src/workflow-capability.ts +417 -0
  599. package/src/workflow-script-flow.test.ts +388 -0
@@ -0,0 +1,2944 @@
1
+ import { Hono } from 'hono'
2
+ import { cors } from 'hono/cors'
3
+ import { createAdaptorServer } from '@hono/node-server'
4
+ import { getConnInfo } from '@hono/node-server/conninfo'
5
+ import { createNodeWebSocket } from '@hono/node-ws'
6
+ import type { WSContext } from 'hono/ws'
7
+ import type { Protocol } from './cdp-types.js'
8
+ import type { CDPCommand, CDPResponseBase, CDPEventBase, CDPEventFor, RelayServerEvents } from './cdp-types.js'
9
+ import {
10
+ EXTENSION_FEATURE,
11
+ RELAY_FEATURES,
12
+ VERSION as EXTENSION_PROTOCOL_VERSION,
13
+ allowsExtensionFeature,
14
+ parseExtensionFeatures,
15
+ requiredExtensionFeatureForMethod,
16
+ type ExtensionMessage,
17
+ type ExtensionEventMessage,
18
+ type ExtensionFeature,
19
+ type RrwebRecordingDataMessage,
20
+ type RrwebRecordingCancelledMessage,
21
+ type StartRrwebRecordingBody,
22
+ type StopRrwebRecordingParams,
23
+ type CancelRrwebRecordingParams,
24
+ type IsRrwebRecordingParams,
25
+ type ToolbarRecordingRequestMessage,
26
+ type ToolbarRecordingResult,
27
+ } from './protocol.js'
28
+ import pc from 'picocolors'
29
+ import util from 'node:util'
30
+
31
+ // Prevent Buffers from dumping hex bytes in util.inspect output.
32
+ Buffer.prototype[util.inspect.custom] = function () {
33
+ return `<Buffer ${this.length} bytes>`
34
+ }
35
+
36
+ import fs from 'node:fs'
37
+ import path from 'node:path'
38
+ import { EventEmitter } from 'node:events'
39
+ import { VERSION, EXTENSION_IDS, shouldAutoEnableTabwright } from './utils.js'
40
+ import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js'
41
+ import {
42
+ RrwebRecordingRelay,
43
+ getSavedRrwebRecording,
44
+ getSavedRrwebRecordingWithEvents,
45
+ listSavedRrwebRecordings,
46
+ } from './rrweb-recording-relay.js'
47
+ import { getCapabilityOptionsDetail, listCapabilityOptions } from './capability-options.js'
48
+ import { getCapabilityAuthState } from './capability-auth-state.js'
49
+ import {
50
+ refreshCapabilityAuthFromCookies,
51
+ type CapabilityAuthCookie,
52
+ } from './capability-auth.js'
53
+ import { requireCapability } from './capability-registry.js'
54
+ import { appendSessionToWsUrl } from './chrome-discovery.js'
55
+ import * as relayState from './relay-state.js'
56
+ import { getTabwrightUserDataDir } from './product-paths.js'
57
+
58
+ /**
59
+ * Checks if a target should be filtered out (not exposed to Playwright).
60
+ * Filters extension pages, service workers, and other restricted targets,
61
+ * but allows our own extension pages for debugging purposes.
62
+ */
63
+ function isRestrictedTarget(targetInfo: Protocol.Target.TargetInfo): boolean {
64
+ const { url, type } = targetInfo
65
+
66
+ // Filter by type - allow pages and iframe targets (OOPIFs)
67
+ if (type !== 'page' && type !== 'iframe') {
68
+ return true
69
+ }
70
+
71
+ // Filter by URL - block extension and chrome internal pages
72
+ if (!url) {
73
+ return false
74
+ }
75
+
76
+ // Allow our own extension pages
77
+ if (url.startsWith('chrome-extension://')) {
78
+ const extensionId = url.replace('chrome-extension://', '').split('/')[0]
79
+ if (EXTENSION_IDS.includes(extensionId)) {
80
+ return false
81
+ }
82
+ return true
83
+ }
84
+
85
+ // Block other restricted URLs
86
+ const blockedPrefixes = ['chrome://', 'devtools://', 'edge://']
87
+ return blockedPrefixes.some((prefix) => url.startsWith(prefix))
88
+ }
89
+
90
+ export type RelayServer = {
91
+ close(): void
92
+ on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
93
+ off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
94
+ }
95
+
96
+ function parseCapabilityAuthCookies(value: unknown): CapabilityAuthCookie[] {
97
+ if (!isRecord(value) || !Array.isArray(value.cookies)) {
98
+ throw new Error('Browser returned an invalid cookie response')
99
+ }
100
+ return value.cookies.flatMap((cookie) => {
101
+ if (!isRecord(cookie) || typeof cookie.name !== 'string' || typeof cookie.value !== 'string') {
102
+ return []
103
+ }
104
+ return [
105
+ {
106
+ name: cookie.name,
107
+ value: cookie.value,
108
+ expires: typeof cookie.expires === 'number' ? cookie.expires : undefined,
109
+ },
110
+ ]
111
+ })
112
+ }
113
+
114
+ function isRecord(value: unknown): value is Record<string, unknown> {
115
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
116
+ }
117
+
118
+ export async function startTabwrightCDPRelayServer({
119
+ port = 19988,
120
+ host = '127.0.0.1',
121
+ token,
122
+ logger,
123
+ cdpLogger,
124
+ }: {
125
+ port?: number
126
+ host?: string
127
+ token?: string
128
+ logger?: { log(...args: any[]): void; error(...args: any[]): void }
129
+ cdpLogger?: CdpLogger
130
+ } = {}): Promise<RelayServer> {
131
+ const emitter = new EventEmitter()
132
+ const store = relayState.createRelayStore()
133
+ const extensionDownloadBehavior = new Map<string, Protocol.Browser.SetDownloadBehaviorRequest>()
134
+
135
+ const resolvedCdpLogger = cdpLogger || createCdpLogger()
136
+ const logCdpJson = (entry: CdpLogEntry) => {
137
+ resolvedCdpLogger.log(entry)
138
+ }
139
+
140
+ const getDefaultExtensionId = (): string | null => {
141
+ return store.getState().extensions.keys().next().value || null
142
+ }
143
+
144
+ /**
145
+ * Resolve an extension by ID, stableKey, or fallback.
146
+ * Returns the unified ExtensionEntry which includes both state and I/O.
147
+ */
148
+ const getExtensionConnection = (
149
+ extensionId?: string | null,
150
+ options: { allowFallback?: boolean } = {},
151
+ ): relayState.ExtensionEntry | null => {
152
+ const currentRelayState = store.getState()
153
+ const { extensions } = currentRelayState
154
+
155
+ if (extensionId) {
156
+ const direct = extensions.get(extensionId)
157
+ if (direct?.ws) {
158
+ return direct
159
+ }
160
+ // Try stableKey lookup.
161
+ const byKey = relayState.findExtensionByStableKey(currentRelayState, extensionId)
162
+ if (byKey) {
163
+ const candidates = Array.from(extensions.values())
164
+ .filter((ext) => ext.stableKey === byKey.stableKey)
165
+ .reverse()
166
+ for (const candidate of candidates) {
167
+ if (candidate.ws) {
168
+ return candidate
169
+ }
170
+ }
171
+ }
172
+ return null
173
+ }
174
+
175
+ if (!options.allowFallback) {
176
+ return null
177
+ }
178
+
179
+ // Single extension — use it directly
180
+ if (extensions.size === 1) {
181
+ const fallbackId = getDefaultExtensionId()
182
+ if (fallbackId) {
183
+ const ext = extensions.get(fallbackId)
184
+ if (ext?.ws) {
185
+ return ext
186
+ }
187
+ }
188
+ }
189
+
190
+ // Multiple extensions — auto-select if exactly one has active targets.
191
+ // This handles the common case of multiple Chrome profiles with the extension
192
+ // installed, where only one profile has Tabwright-enabled tabs. (#52)
193
+ if (extensions.size > 1) {
194
+ const activeExtensions = Array.from(extensions.values()).filter((ext) => {
195
+ return ext.connectedTargets.size > 0
196
+ })
197
+ if (activeExtensions.length === 1 && activeExtensions[0].ws) {
198
+ return activeExtensions[0]
199
+ }
200
+ }
201
+
202
+ return null
203
+ }
204
+
205
+ const extensionAllowsFeature = (options: {
206
+ extensionId: string
207
+ feature: ExtensionFeature
208
+ }): boolean => {
209
+ const extension = store.getState().extensions.get(options.extensionId)
210
+ return allowsExtensionFeature({ features: extension?.info.features, feature: options.feature })
211
+ }
212
+
213
+ const extensionSupportsFeature = (options: {
214
+ extensionId: string
215
+ feature: ExtensionFeature
216
+ }): boolean => {
217
+ const extension = store.getState().extensions.get(options.extensionId)
218
+ return extension?.info.features?.includes(options.feature) || false
219
+ }
220
+
221
+ const normalizeSessionId = (value: string | number | null | undefined): string | null => {
222
+ if (value === undefined || value === null) {
223
+ return null
224
+ }
225
+ const normalized = String(value)
226
+ return normalized ? normalized : null
227
+ }
228
+
229
+ const getPageTargetForFrameId = ({
230
+ extensionState,
231
+ frameId,
232
+ }: {
233
+ extensionState: relayState.ExtensionEntry
234
+ frameId: string
235
+ }): relayState.ConnectedTarget | undefined => {
236
+ return Array.from(extensionState.connectedTargets.values()).find((target) => {
237
+ return target.targetInfo.type === 'page' && target.frameIds.has(frameId)
238
+ })
239
+ }
240
+
241
+ const startExtensionPing = (extensionId: string): void => {
242
+ const ext = store.getState().extensions.get(extensionId)
243
+ if (!ext) {
244
+ return
245
+ }
246
+ if (!extensionAllowsFeature({ extensionId, feature: EXTENSION_FEATURE.heartbeat })) {
247
+ return
248
+ }
249
+ if (ext.pingInterval) {
250
+ clearInterval(ext.pingInterval)
251
+ }
252
+
253
+ const pingInterval = setInterval(() => {
254
+ const latestExt = store.getState().extensions.get(extensionId)
255
+ if (!latestExt?.ws) {
256
+ return
257
+ }
258
+ if (
259
+ relayState.hasExtensionHeartbeatExpired({
260
+ lastPongAt: latestExt.lastPongAt,
261
+ now: Date.now(),
262
+ })
263
+ ) {
264
+ logger?.log(pc.yellow(`Terminating unresponsive extension heartbeat (${extensionId})`))
265
+ if (latestExt.ws.raw) {
266
+ latestExt.ws.raw.terminate()
267
+ return
268
+ }
269
+ latestExt.ws.close(4000, 'Extension heartbeat timeout')
270
+ return
271
+ }
272
+ latestExt.ws.send(JSON.stringify({ method: 'ping' }))
273
+ }, 5000)
274
+
275
+ store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval }))
276
+ }
277
+
278
+ const stopExtensionPing = (extensionId: string): void => {
279
+ const ext = store.getState().extensions.get(extensionId)
280
+ if (!ext || !ext.pingInterval) {
281
+ return
282
+ }
283
+ clearInterval(ext.pingInterval)
284
+ store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval: null }))
285
+ }
286
+
287
+ function logCdpMessage({
288
+ direction,
289
+ clientId,
290
+ method,
291
+ sessionId,
292
+ params,
293
+ id,
294
+ source,
295
+ }: {
296
+ direction: 'to-playwright' | 'from-playwright' | 'from-extension'
297
+ clientId?: string
298
+ method: string
299
+ sessionId?: string
300
+ params?: any
301
+ id?: number
302
+ source?: 'extension' | 'server'
303
+ }) {
304
+ const noisyEvents = [
305
+ 'Network.requestWillBeSentExtraInfo',
306
+ 'Network.responseReceived',
307
+ 'Network.responseReceivedExtraInfo',
308
+ 'Network.dataReceived',
309
+ 'Network.requestWillBeSent',
310
+ 'Network.loadingFinished',
311
+ ]
312
+
313
+ if (noisyEvents.includes(method)) {
314
+ return
315
+ }
316
+
317
+ const details: string[] = []
318
+
319
+ if (id !== undefined) {
320
+ details.push(`id=${id}`)
321
+ }
322
+
323
+ if (sessionId) {
324
+ details.push(`sessionId=${sessionId}`)
325
+ }
326
+
327
+ if (params) {
328
+ if (params.targetId) {
329
+ details.push(`targetId=${params.targetId}`)
330
+ }
331
+ if (params.targetInfo?.targetId) {
332
+ details.push(`targetId=${params.targetInfo.targetId}`)
333
+ }
334
+ if (params.sessionId && params.sessionId !== sessionId) {
335
+ details.push(`sessionId=${params.sessionId}`)
336
+ }
337
+ }
338
+
339
+ const detailsStr = details.length > 0 ? ` ${pc.gray(details.join(', '))}` : ''
340
+
341
+ if (direction === 'from-playwright') {
342
+ const clientLabel = clientId ? pc.blue(`[${clientId}]`) : ''
343
+ logger?.log(pc.cyan('← Playwright'), clientLabel + ':', method + detailsStr)
344
+ } else if (direction === 'from-extension') {
345
+ logger?.log(pc.yellow('← Extension:'), method + detailsStr)
346
+ } else if (direction === 'to-playwright') {
347
+ const color = source === 'server' ? pc.magenta : pc.green
348
+ const sourceLabel = source === 'server' ? pc.gray(' (server-generated)') : ''
349
+ const clientLabel = clientId ? pc.blue(`[${clientId}]`) : pc.blue('[ALL]')
350
+ logger?.log(color('→ Playwright'), clientLabel + ':', method + detailsStr + sourceLabel)
351
+ }
352
+ }
353
+
354
+ function sendToPlaywright({
355
+ message,
356
+ clientId,
357
+ source = 'extension',
358
+ extensionId,
359
+ }: {
360
+ message: CDPResponseBase | CDPEventBase
361
+ clientId?: string
362
+ source?: 'extension' | 'server'
363
+ extensionId?: string | null
364
+ }) {
365
+ const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message
366
+
367
+ logCdpJson({
368
+ timestamp: new Date().toISOString(),
369
+ direction: 'to-playwright',
370
+ clientId,
371
+ source,
372
+ message: messageToSend,
373
+ })
374
+
375
+ if ('method' in message) {
376
+ logCdpMessage({
377
+ direction: 'to-playwright',
378
+ clientId,
379
+ method: message.method,
380
+ sessionId: 'sessionId' in message ? message.sessionId : undefined,
381
+ params: 'params' in message ? message.params : undefined,
382
+ source,
383
+ })
384
+ }
385
+
386
+ const messageStr = JSON.stringify(messageToSend)
387
+
388
+ // Helper to safely send to a WebSocket, catching errors from closing connections.
389
+ // When a Playwright client closes its WebSocket, there's a race window where:
390
+ // 1. Playwright's _onClose runs (clears callbacks map)
391
+ // 2. We might still have messages in flight or try to send
392
+ // This can cause "Assertion error" in Playwright's crConnection.js if a response
393
+ // arrives after callbacks were cleared. We wrap in try-catch to handle this gracefully.
394
+ const safeSend = (client: relayState.PlaywrightClient) => {
395
+ try {
396
+ client.ws.send(messageStr)
397
+ } catch (e) {
398
+ // WebSocket might be closing/closed - this is expected during disconnect
399
+ logger?.log(pc.gray(`[Relay] Skipped sending to closing client ${client.id}: ${(e as Error).message}`))
400
+ }
401
+ }
402
+
403
+ if (clientId) {
404
+ const client = store.getState().playwrightClients.get(clientId)
405
+ if (client) {
406
+ safeSend(client)
407
+ }
408
+ } else {
409
+ const { playwrightClients } = store.getState()
410
+ for (const client of playwrightClients.values()) {
411
+ if (extensionId && client.extensionId !== extensionId) {
412
+ continue
413
+ }
414
+ safeSend(client)
415
+ }
416
+ }
417
+ }
418
+
419
+ type ForwardCdpParams = {
420
+ method: string
421
+ sessionId?: string
422
+ params?: unknown
423
+ }
424
+
425
+ function getForwardCdpParams(value: unknown): ForwardCdpParams | undefined {
426
+ if (!value || typeof value !== 'object') {
427
+ return undefined
428
+ }
429
+ const record = value as { method?: unknown; sessionId?: unknown; params?: unknown }
430
+ if (typeof record.method !== 'string') {
431
+ return undefined
432
+ }
433
+ const sessionId = typeof record.sessionId === 'string' ? record.sessionId : undefined
434
+ return { method: record.method, sessionId, params: record.params }
435
+ }
436
+
437
+ async function sendToExtension({
438
+ extensionId,
439
+ method,
440
+ params,
441
+ timeout = 30000,
442
+ }: {
443
+ extensionId?: string | null
444
+ method: string
445
+ params?: unknown
446
+ timeout?: number
447
+ }): Promise<unknown> {
448
+ const conn = getExtensionConnection(extensionId)
449
+ if (!conn) {
450
+ throw new Error('Extension not connected')
451
+ }
452
+ const resolvedExtensionId = conn.id
453
+ const requiredFeature = requiredExtensionFeatureForMethod(method)
454
+ if (requiredFeature && !extensionAllowsFeature({ extensionId: resolvedExtensionId, feature: requiredFeature })) {
455
+ throw new Error(
456
+ `Extension ${conn.stableKey} does not advertise required feature ${requiredFeature}. Update the extension to use this operation.`,
457
+ )
458
+ }
459
+
460
+ let id = 0
461
+ store.setState((s) => {
462
+ const ext = s.extensions.get(resolvedExtensionId)
463
+ if (!ext) {
464
+ return s
465
+ }
466
+ id = ext.messageId + 1
467
+ const newExtensions = new Map(s.extensions)
468
+ newExtensions.set(resolvedExtensionId, { ...ext, messageId: id })
469
+ return { ...s, extensions: newExtensions }
470
+ })
471
+
472
+ if (!id) {
473
+ throw new Error('Extension not connected')
474
+ }
475
+
476
+ const message = { id, method, params }
477
+
478
+ const forwardCdpParams = method === 'forwardCDPCommand' ? getForwardCdpParams(params) : undefined
479
+ if (forwardCdpParams) {
480
+ logCdpJson({
481
+ timestamp: new Date().toISOString(),
482
+ direction: 'to-extension',
483
+ message: {
484
+ method: forwardCdpParams.method,
485
+ sessionId: forwardCdpParams.sessionId,
486
+ params: forwardCdpParams.params,
487
+ },
488
+ })
489
+ }
490
+
491
+ return new Promise((resolve, reject) => {
492
+ const timeoutId = setTimeout(() => {
493
+ store.setState((s) =>
494
+ relayState.removeExtensionPendingRequest(s, {
495
+ extensionId: resolvedExtensionId,
496
+ requestId: id,
497
+ }),
498
+ )
499
+ reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`))
500
+ }, timeout)
501
+
502
+ const pendingRequest = {
503
+ resolve: (result) => {
504
+ clearTimeout(timeoutId)
505
+ resolve(result)
506
+ },
507
+ reject: (error) => {
508
+ clearTimeout(timeoutId)
509
+ reject(error)
510
+ },
511
+ }
512
+
513
+ store.setState((s) =>
514
+ relayState.addExtensionPendingRequest(s, {
515
+ extensionId: resolvedExtensionId,
516
+ requestId: id,
517
+ pendingRequest,
518
+ }),
519
+ )
520
+
521
+ const latestExt = store.getState().extensions.get(resolvedExtensionId)
522
+ if (!latestExt?.ws) {
523
+ clearTimeout(timeoutId)
524
+ store.setState((s) =>
525
+ relayState.removeExtensionPendingRequest(s, {
526
+ extensionId: resolvedExtensionId,
527
+ requestId: id,
528
+ }),
529
+ )
530
+ reject(new Error('Extension not connected'))
531
+ return
532
+ }
533
+
534
+ try {
535
+ latestExt.ws.send(JSON.stringify(message))
536
+ } catch (error) {
537
+ clearTimeout(timeoutId)
538
+ store.setState((s) =>
539
+ relayState.removeExtensionPendingRequest(s, {
540
+ extensionId: resolvedExtensionId,
541
+ requestId: id,
542
+ }),
543
+ )
544
+ const sendError = error instanceof Error ? error : new Error(String(error))
545
+ reject(new Error(`Extension send failed: ${method}`, { cause: sendError }))
546
+ }
547
+ })
548
+ }
549
+
550
+ const rrwebRecordingRelays = new Map<string, RrwebRecordingRelay>()
551
+
552
+ // Find which extension connection owns a CDP tab session ID (pw-tab-*).
553
+ // Used by recording routes where sessionId identifies the target tab.
554
+ // Delegates to the pure derivation function from relay-state.ts.
555
+ const findExtensionIdByCdpSession = (cdpSessionId: string): string | null => {
556
+ return relayState.findExtensionIdByCdpSession(store.getState(), cdpSessionId)
557
+ }
558
+
559
+ // Resolve recording route session ID (CDP tab session) to extension connection.
560
+ const resolveRecordingRoute = async ({
561
+ sessionId,
562
+ }: {
563
+ sessionId: string | null
564
+ }): Promise<{
565
+ extensionId: string | null
566
+ sessionId: string | null
567
+ }> => {
568
+ if (!sessionId) {
569
+ return { extensionId: null, sessionId: null }
570
+ }
571
+
572
+ const extensionId = findExtensionIdByCdpSession(sessionId)
573
+ return { extensionId, sessionId }
574
+ }
575
+
576
+ const getRrwebRecordingRelay = (extensionId?: string | null): RrwebRecordingRelay | null => {
577
+ const allowDefault = !extensionId && store.getState().extensions.size === 1
578
+ const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault })
579
+ if (!conn) {
580
+ return null
581
+ }
582
+ const connId = conn.id
583
+ if (!rrwebRecordingRelays.has(connId)) {
584
+ rrwebRecordingRelays.set(
585
+ connId,
586
+ new RrwebRecordingRelay(
587
+ (params) => sendToExtension({ extensionId: connId, ...params }),
588
+ () => store.getState().extensions.has(connId),
589
+ logger,
590
+ ),
591
+ )
592
+ }
593
+ return rrwebRecordingRelays.get(connId) || null
594
+ }
595
+
596
+ const handleToolbarRecordingRequest = async (
597
+ extensionId: string,
598
+ message: ToolbarRecordingRequestMessage,
599
+ ): Promise<ToolbarRecordingResult> => {
600
+ logger?.log(
601
+ pc.blue(
602
+ `Toolbar recording request: ${message.params.action} requestId=${message.params.requestId} sessionId=${message.params.sessionId || 'none'}`,
603
+ ),
604
+ )
605
+ const rrwebRelay = getRrwebRecordingRelay(extensionId)
606
+ if (!rrwebRelay) {
607
+ logger?.log(pc.yellow(`Toolbar recording request failed: extension not connected requestId=${message.params.requestId}`))
608
+ return { success: false, isRecording: false, error: 'Extension not connected' }
609
+ }
610
+
611
+ const params = message.params.sessionId ? { sessionId: message.params.sessionId } : {}
612
+ const rrwebStatus = await rrwebRelay.isRecording(params)
613
+ const isRecording = rrwebStatus.isRecording
614
+ logger?.log(
615
+ pc.blue(
616
+ `Toolbar replay recording status: rrweb=${rrwebStatus.isRecording} requestId=${message.params.requestId} tabId=${rrwebStatus.tabId || 'none'}`,
617
+ ),
618
+ )
619
+
620
+ if (message.params.action === 'status') {
621
+ return {
622
+ success: true,
623
+ isRecording,
624
+ startedAt: rrwebStatus.startedAt,
625
+ tabId: rrwebStatus.tabId,
626
+ }
627
+ }
628
+
629
+ if (isRecording) {
630
+ const rrwebStopResult = await rrwebRelay.stopRecording(params)
631
+
632
+ if (!rrwebStopResult.success) {
633
+ const error = rrwebStopResult.error
634
+ logger?.log(pc.yellow(`Toolbar recording stop failed requestId=${message.params.requestId}: ${error}`))
635
+ return { success: false, isRecording: true, error }
636
+ }
637
+
638
+ return {
639
+ success: true,
640
+ isRecording: false,
641
+ id: rrwebStopResult.id,
642
+ tabId: rrwebStopResult.tabId,
643
+ path: rrwebStopResult.path,
644
+ duration: rrwebStopResult.duration,
645
+ size: rrwebStopResult.size,
646
+ replayId: rrwebStopResult.id,
647
+ replayPath: rrwebStopResult.path,
648
+ replayDuration: rrwebStopResult.duration,
649
+ replaySize: rrwebStopResult.size,
650
+ replayEventCount: rrwebStopResult.eventCount,
651
+ }
652
+ }
653
+
654
+ const rrwebStartResult = await rrwebRelay.startRecording({
655
+ ...params,
656
+ checkoutEveryNms: 0,
657
+ maskAllInputs: false,
658
+ })
659
+
660
+ if (!rrwebStartResult.success) {
661
+ const error = rrwebStartResult.error || 'Failed to start replay recording'
662
+ logger?.log(pc.yellow(`Toolbar recording start failed requestId=${message.params.requestId}: ${error}`))
663
+ return { success: false, isRecording: false, error }
664
+ }
665
+
666
+ return {
667
+ success: true,
668
+ isRecording: true,
669
+ startedAt: rrwebStartResult.startedAt,
670
+ tabId: rrwebStartResult.tabId,
671
+ }
672
+ }
673
+
674
+ type InitialTabTarget = {
675
+ tabId: number
676
+ sessionId: string
677
+ targetInfo: Protocol.Target.TargetInfo
678
+ }
679
+
680
+ async function createInitialTabTarget(options: { extensionId: string }): Promise<InitialTabTarget> {
681
+ const result = (await sendToExtension({
682
+ extensionId: options.extensionId,
683
+ method: 'createInitialTab',
684
+ timeout: 10000,
685
+ })) as Partial<InitialTabTarget> & { success?: boolean }
686
+ if (!result.success || !result.tabId || !result.sessionId || !result.targetInfo) {
687
+ throw new Error('Tabwright could not create a temporary browser tab')
688
+ }
689
+ const target: InitialTabTarget = {
690
+ tabId: result.tabId,
691
+ sessionId: result.sessionId,
692
+ targetInfo: result.targetInfo,
693
+ }
694
+ store.setState((state) =>
695
+ relayState.addTarget(state, {
696
+ extensionId: options.extensionId,
697
+ sessionId: target.sessionId,
698
+ targetId: target.targetInfo.targetId,
699
+ targetInfo: target.targetInfo,
700
+ }),
701
+ )
702
+ const updatedTargets = store.getState().extensions.get(options.extensionId)?.connectedTargets.size || 0
703
+ logger?.log(pc.blue(`Auto-created tab, now have ${updatedTargets} targets, url: ${target.targetInfo.url}`))
704
+ return target
705
+ }
706
+
707
+ // Auto-create an initial blank tab when no targets exist. Set
708
+ // PLAYWRITER_AUTO_ENABLE=false to require manually enabled tabs instead.
709
+ async function maybeAutoCreateInitialTab(extensionId: string): Promise<void> {
710
+ if (!shouldAutoEnableTabwright()) {
711
+ return
712
+ }
713
+ const conn = getExtensionConnection(extensionId)
714
+ if (!conn) {
715
+ return
716
+ }
717
+ if (!extensionAllowsFeature({ extensionId: conn.id, feature: EXTENSION_FEATURE.createInitialTab })) {
718
+ return
719
+ }
720
+ if (conn.connectedTargets.size > 0) {
721
+ return
722
+ }
723
+
724
+ try {
725
+ logger?.log(pc.blue('Auto-creating initial tab for Playwright client'))
726
+ await createInitialTabTarget({ extensionId })
727
+ } catch (error: unknown) {
728
+ logger?.error('Failed to auto-create initial tab:', error)
729
+ }
730
+ }
731
+
732
+ function getPageTargetSessionIds({ extensionId }: { extensionId: string }): string[] {
733
+ const extensionState = store.getState().extensions.get(extensionId)
734
+ if (!extensionState) {
735
+ return []
736
+ }
737
+ return Array.from(extensionState.connectedTargets.values())
738
+ .filter((target) => {
739
+ return target.targetInfo.type === 'page'
740
+ })
741
+ .map((target) => {
742
+ return target.sessionId
743
+ })
744
+ }
745
+
746
+ type CapabilityAuthSession =
747
+ | { ok: true; sessionId: string; temporaryTargetId?: string }
748
+ | { ok: false; code: 'no_enabled_tab' | 'temporary_tab_failed'; error: string }
749
+
750
+ async function getCapabilityAuthSession(options: { extensionId: string }): Promise<CapabilityAuthSession> {
751
+ const existingSessionId = getPageTargetSessionIds({ extensionId: options.extensionId })[0]
752
+ if (existingSessionId) {
753
+ return { ok: true, sessionId: existingSessionId }
754
+ }
755
+ if (
756
+ !extensionSupportsFeature({
757
+ extensionId: options.extensionId,
758
+ feature: EXTENSION_FEATURE.createInitialTab,
759
+ })
760
+ ) {
761
+ return {
762
+ ok: false,
763
+ code: 'no_enabled_tab',
764
+ error: 'enable Tabwright on a browser tab before authenticating this capability',
765
+ }
766
+ }
767
+ try {
768
+ // Authentication is already user-confirmed in Options. A temporary inactive tab lets
769
+ // chrome.debugger read profile cookies without requiring another extension-icon click.
770
+ const target = await createInitialTabTarget({ extensionId: options.extensionId })
771
+ return {
772
+ ok: true,
773
+ sessionId: target.sessionId,
774
+ temporaryTargetId: target.targetInfo.targetId,
775
+ }
776
+ } catch (error: unknown) {
777
+ return {
778
+ ok: false,
779
+ code: 'temporary_tab_failed',
780
+ error: error instanceof Error ? error.message : String(error),
781
+ }
782
+ }
783
+ }
784
+
785
+ function maybeEmitBrowserDownloadCompatEvent({
786
+ method,
787
+ params,
788
+ extensionId,
789
+ }: {
790
+ method: string
791
+ params: unknown
792
+ extensionId: string
793
+ }): void {
794
+ const browserEventMethod =
795
+ method === 'Page.downloadWillBegin'
796
+ ? 'Browser.downloadWillBegin'
797
+ : method === 'Page.downloadProgress'
798
+ ? 'Browser.downloadProgress'
799
+ : null
800
+ if (!browserEventMethod) {
801
+ return
802
+ }
803
+ sendToPlaywright({
804
+ message: {
805
+ method: browserEventMethod,
806
+ params,
807
+ } as CDPEventBase,
808
+ source: 'server',
809
+ extensionId,
810
+ })
811
+ }
812
+
813
+ async function applyDownloadBehaviorToTargets({
814
+ extensionId,
815
+ behavior,
816
+ source,
817
+ targetSessionIds,
818
+ }: {
819
+ extensionId: string
820
+ behavior: Protocol.Browser.SetDownloadBehaviorRequest
821
+ source?: CDPCommand['source']
822
+ targetSessionIds?: string[]
823
+ }): Promise<void> {
824
+ const pageBehavior: Protocol.Page.SetDownloadBehaviorRequest['behavior'] =
825
+ behavior.behavior === 'allowAndName' ? 'allow' : behavior.behavior
826
+ const pageParams: Protocol.Page.SetDownloadBehaviorRequest = (() => {
827
+ if (pageBehavior === 'allow' && behavior.downloadPath) {
828
+ return { behavior: pageBehavior, downloadPath: behavior.downloadPath }
829
+ }
830
+ return { behavior: pageBehavior }
831
+ })()
832
+ const sessions = targetSessionIds || getPageTargetSessionIds({ extensionId })
833
+ if (sessions.length === 0) {
834
+ return
835
+ }
836
+ await Promise.all(
837
+ sessions.map(async (targetSessionId) => {
838
+ try {
839
+ await sendToExtension({
840
+ extensionId,
841
+ method: 'forwardCDPCommand',
842
+ params: {
843
+ sessionId: targetSessionId,
844
+ method: 'Page.setDownloadBehavior',
845
+ params: pageParams,
846
+ source,
847
+ },
848
+ })
849
+ } catch (error) {
850
+ const message = error instanceof Error ? error.message : String(error)
851
+ logger?.log(pc.yellow(`[Server] Failed to apply Page.setDownloadBehavior to ${targetSessionId}: ${message}`))
852
+ }
853
+ }),
854
+ )
855
+ }
856
+
857
+ async function routeCdpCommand({
858
+ extensionId,
859
+ method,
860
+ params,
861
+ sessionId,
862
+ source,
863
+ }: {
864
+ extensionId: string | null
865
+ method: CDPCommand['method'] | (string & {})
866
+ params: CDPCommand['params']
867
+ sessionId?: CDPCommand['sessionId']
868
+ source?: CDPCommand['source']
869
+ }) {
870
+ const conn = getExtensionConnection(extensionId)
871
+ const connectedTargets = conn?.connectedTargets || new Map<string, relayState.ConnectedTarget>()
872
+ const resolvedExtensionId = conn?.id || extensionId
873
+ switch (method) {
874
+ case 'Browser.getVersion': {
875
+ return {
876
+ protocolVersion: '1.3',
877
+ product: 'Chrome/Extension-Bridge',
878
+ revision: '1.0.0',
879
+ userAgent: 'CDP-Bridge-Server/1.0.0',
880
+ jsVersion: 'V8',
881
+ } satisfies Protocol.Browser.GetVersionResponse
882
+ }
883
+
884
+ case 'Browser.setDownloadBehavior': {
885
+ const downloadBehaviorParams = params as Protocol.Browser.SetDownloadBehaviorRequest | undefined
886
+ if (!downloadBehaviorParams?.behavior) {
887
+ throw new Error('behavior is required for Browser.setDownloadBehavior')
888
+ }
889
+ if (resolvedExtensionId) {
890
+ extensionDownloadBehavior.set(resolvedExtensionId, downloadBehaviorParams)
891
+ await applyDownloadBehaviorToTargets({
892
+ extensionId: resolvedExtensionId,
893
+ behavior: downloadBehaviorParams,
894
+ source,
895
+ })
896
+ }
897
+ return {}
898
+ }
899
+
900
+ // Target.setAutoAttach is a CDP command Playwright sends on first connection.
901
+ // We use it as the hook to auto-create an initial tab. If Playwright changes
902
+ // its initialization sequence in the future, this could be moved to a different command.
903
+ case 'Target.setAutoAttach': {
904
+ if (sessionId) {
905
+ break
906
+ }
907
+ if (conn) {
908
+ await maybeAutoCreateInitialTab(conn.id)
909
+ }
910
+ // Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
911
+ // Playwright relies on these (with parentFrameId) when reconnecting over CDP.
912
+ await sendToExtension({
913
+ extensionId: resolvedExtensionId,
914
+ method: 'forwardCDPCommand',
915
+ params: { method, params, source },
916
+ })
917
+ return {}
918
+ }
919
+
920
+ case 'Target.setDiscoverTargets': {
921
+ return {}
922
+ }
923
+
924
+ case 'Target.attachToTarget': {
925
+ const attachParams = params as Protocol.Target.AttachToTargetRequest
926
+ if (!attachParams?.targetId) {
927
+ throw new Error('targetId is required for Target.attachToTarget')
928
+ }
929
+
930
+ for (const target of connectedTargets.values()) {
931
+ if (target.targetId === attachParams.targetId) {
932
+ return { sessionId: target.sessionId } satisfies Protocol.Target.AttachToTargetResponse
933
+ }
934
+ }
935
+
936
+ throw new Error(`Target ${attachParams.targetId} not found in connected targets`)
937
+ }
938
+
939
+ case 'Target.getTargetInfo': {
940
+ const infoReqParams = params as Protocol.Target.GetTargetInfoRequest | undefined
941
+ const targetId = infoReqParams?.targetId
942
+
943
+ if (targetId) {
944
+ for (const target of connectedTargets.values()) {
945
+ if (target.targetId === targetId) {
946
+ return { targetInfo: target.targetInfo }
947
+ }
948
+ }
949
+ }
950
+
951
+ if (sessionId) {
952
+ const target = connectedTargets.get(sessionId)
953
+ if (target) {
954
+ return { targetInfo: target.targetInfo }
955
+ }
956
+ }
957
+
958
+ const firstTarget = Array.from(connectedTargets.values())[0]
959
+ return { targetInfo: firstTarget?.targetInfo }
960
+ }
961
+
962
+ case 'Target.getTargets': {
963
+ return {
964
+ targetInfos: Array.from(connectedTargets.values())
965
+ .filter((t) => !isRestrictedTarget(t.targetInfo))
966
+ .map((t) => ({
967
+ ...t.targetInfo,
968
+ attached: true,
969
+ })),
970
+ }
971
+ }
972
+
973
+ case 'Target.createTarget': {
974
+ return await sendToExtension({
975
+ extensionId: resolvedExtensionId,
976
+ method: 'forwardCDPCommand',
977
+ params: { method, params, source },
978
+ })
979
+ }
980
+
981
+ case 'Target.closeTarget': {
982
+ return await sendToExtension({
983
+ extensionId: resolvedExtensionId,
984
+ method: 'forwardCDPCommand',
985
+ params: { method, params, source },
986
+ })
987
+ }
988
+
989
+ // Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects
990
+ case 'ghost-browser': {
991
+ return await sendToExtension({
992
+ extensionId: resolvedExtensionId,
993
+ method: 'ghost-browser',
994
+ params,
995
+ })
996
+ }
997
+
998
+ case 'Runtime.enable': {
999
+ if (!sessionId) {
1000
+ break
1001
+ }
1002
+
1003
+ const contextCreatedPromise = new Promise<void>((resolve) => {
1004
+ const handler = ({ event }: { event: CDPEventBase }) => {
1005
+ if (event.method === 'Runtime.executionContextCreated' && event.sessionId === sessionId) {
1006
+ const params = event.params as Protocol.Runtime.ExecutionContextCreatedEvent | undefined
1007
+ if (params?.context?.auxData?.isDefault === true) {
1008
+ clearTimeout(timeout)
1009
+ emitter.off('cdp:event', handler)
1010
+ resolve()
1011
+ }
1012
+ }
1013
+ }
1014
+ const timeout = setTimeout(() => {
1015
+ emitter.off('cdp:event', handler)
1016
+ logger?.log(
1017
+ pc.yellow(
1018
+ `IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`,
1019
+ ),
1020
+ )
1021
+ resolve()
1022
+ }, 3000)
1023
+ emitter.on('cdp:event', handler)
1024
+ })
1025
+
1026
+ const result = await sendToExtension({
1027
+ extensionId: resolvedExtensionId,
1028
+ method: 'forwardCDPCommand',
1029
+ params: { sessionId, method, params, source },
1030
+ })
1031
+
1032
+ await contextCreatedPromise
1033
+
1034
+ return result
1035
+ }
1036
+ }
1037
+
1038
+ return await sendToExtension({
1039
+ extensionId: resolvedExtensionId,
1040
+ method: 'forwardCDPCommand',
1041
+ params: { sessionId, method, params, source },
1042
+ })
1043
+ }
1044
+
1045
+ const app = new Hono()
1046
+
1047
+ // Global error handler — ensures server errors are logged, not silently swallowed
1048
+ app.onError((err, c) => {
1049
+ logger?.error('Unhandled route error:', err)
1050
+ return c.json({ error: err.message }, 500)
1051
+ })
1052
+
1053
+ // CORS middleware for HTTP endpoints - only allows our specific extension IDs.
1054
+ // This prevents other extensions from reading responses via fetch/XHR.
1055
+ // WebSocket connections have their own separate origin validation.
1056
+ app.use(
1057
+ '*',
1058
+ cors({
1059
+ origin: (origin) => {
1060
+ if (!origin.startsWith('chrome-extension://')) {
1061
+ return null
1062
+ }
1063
+ const extensionId = origin.replace('chrome-extension://', '')
1064
+ if (!EXTENSION_IDS.includes(extensionId)) {
1065
+ return null
1066
+ }
1067
+ return origin
1068
+ },
1069
+ allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'],
1070
+ }),
1071
+ )
1072
+ // Host header validation to prevent DNS rebinding attacks.
1073
+ // DNS rebinding is worse than a simple cross-origin request: the attacker
1074
+ // serves a page from http://evil.com:19988, then rebinds the DNS to
1075
+ // 127.0.0.1. The browser now considers requests to our relay as same-origin,
1076
+ // so Sec-Fetch-Site is "same-origin", CORS doesn't apply, and JSON POSTs
1077
+ // don't need preflight. This bypasses all our other defenses.
1078
+ // By rejecting any Host that isn't a known localhost value we kill DNS
1079
+ // rebinding at the root. When a valid token is provided (remote access), we
1080
+ // allow through regardless of Host since remote clients use real hostnames.
1081
+ const ALLOWED_HOSTS = new Set([
1082
+ 'localhost',
1083
+ '127.0.0.1',
1084
+ '[::1]',
1085
+ '::1',
1086
+ ])
1087
+
1088
+ // Parse the Host header into just the hostname, handling IPv6 brackets and
1089
+ // port suffixes. Returns null for missing or malformed values.
1090
+ function parseHostname(hostHeader: string | undefined): string | null {
1091
+ const value = hostHeader?.trim().toLowerCase()
1092
+ if (!value) {
1093
+ return null
1094
+ }
1095
+ // IPv6 in brackets: [::1] or [::1]:19988
1096
+ if (value.startsWith('[')) {
1097
+ const closingBracket = value.indexOf(']')
1098
+ if (closingBracket === -1) {
1099
+ return null
1100
+ }
1101
+ const host = value.slice(0, closingBracket + 1)
1102
+ const rest = value.slice(closingBracket + 1)
1103
+ if (rest && !/^:\d+$/.test(rest)) {
1104
+ return null
1105
+ }
1106
+ return host
1107
+ }
1108
+ // Bare ::1 without brackets (uncommon but possible)
1109
+ if (value === '::1') {
1110
+ return '::1'
1111
+ }
1112
+ // hostname or hostname:port
1113
+ const colonIndex = value.indexOf(':')
1114
+ if (colonIndex === -1) {
1115
+ return value
1116
+ }
1117
+ const host = value.slice(0, colonIndex)
1118
+ const portPart = value.slice(colonIndex + 1)
1119
+ if (!/^\d+$/.test(portPart)) {
1120
+ return null
1121
+ }
1122
+ return host || null
1123
+ }
1124
+
1125
+ function hasValidToken(c: { req: { header: (name: string) => string | undefined; url: string } }): boolean {
1126
+ if (!token) {
1127
+ return false
1128
+ }
1129
+ const authHeader = c.req.header('authorization') || ''
1130
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null
1131
+ const queryToken = new URL(c.req.url, 'http://localhost').searchParams.get('token')
1132
+ return bearerToken === token || queryToken === token
1133
+ }
1134
+
1135
+ app.use('*', async (c, next) => {
1136
+ const hostname = parseHostname(c.req.header('host'))
1137
+ if (hostname && ALLOWED_HOSTS.has(hostname)) {
1138
+ return next()
1139
+ }
1140
+ // Remote clients with a valid token are allowed regardless of Host
1141
+ if (hasValidToken(c)) {
1142
+ return next()
1143
+ }
1144
+ // Missing Host header from non-browser clients (curl without Host) is fine
1145
+ // in local mode since they're not browser-based DNS rebinding attacks
1146
+ if (!hostname && !token) {
1147
+ return next()
1148
+ }
1149
+ logger?.log(pc.red(`Rejecting request with unexpected Host header: ${c.req.header('host')} (DNS rebinding protection)`))
1150
+ return c.text('Forbidden - Invalid Host header', 403)
1151
+ })
1152
+
1153
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
1154
+
1155
+ const getCdpWsUrl = (c: { req: { header: (name: string) => string | undefined } }) => {
1156
+ const hostHeader = c.req.header('host') || `${host}:${port}`
1157
+ return `ws://${hostHeader}/cdp`
1158
+ }
1159
+
1160
+ app.get('/', (c) => {
1161
+ return c.text('OK')
1162
+ })
1163
+
1164
+ app.get('/version', (c) => {
1165
+ return c.json({ version: VERSION })
1166
+ })
1167
+
1168
+ app.get('/features', (c) => {
1169
+ return c.json({ protocolVersion: EXTENSION_PROTOCOL_VERSION, features: RELAY_FEATURES })
1170
+ })
1171
+
1172
+ app.get('/extension/status', (c) => {
1173
+ const defaultExtension = getExtensionConnection(null, { allowFallback: true })
1174
+ const connected = store.getState().extensions.size > 0
1175
+ const activeTargets = defaultExtension?.connectedTargets.size || 0
1176
+ const info = defaultExtension?.info
1177
+ const negotiation = relayState.getExtensionNegotiationStatus(info)
1178
+
1179
+ return c.json({
1180
+ connected,
1181
+ activeTargets,
1182
+ browser: info?.browser || null,
1183
+ profile: info ? { email: info.email || '', id: info.id || '' } : null,
1184
+ playwriterVersion: info?.version || null,
1185
+ protocolVersion: info?.protocolVersion,
1186
+ features: info?.features,
1187
+ ...negotiation,
1188
+ })
1189
+ })
1190
+
1191
+ app.get('/extensions/status', (c) => {
1192
+ const extensions = Array.from(store.getState().extensions.values()).map((ext) => {
1193
+ const negotiation = relayState.getExtensionNegotiationStatus(ext.info)
1194
+ return {
1195
+ extensionId: ext.id,
1196
+ stableKey: ext.stableKey,
1197
+ browser: ext.info.browser || null,
1198
+ profile: ext.info ? { email: ext.info.email || '', id: ext.info.id || '' } : null,
1199
+ activeTargets: ext.connectedTargets.size,
1200
+ playwriterVersion: ext.info?.version || null,
1201
+ protocolVersion: ext.info.protocolVersion,
1202
+ features: ext.info.features,
1203
+ ...negotiation,
1204
+ }
1205
+ })
1206
+ return c.json({ extensions })
1207
+ })
1208
+
1209
+ // CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API
1210
+ // Allows tools like Playwright to discover the WebSocket URL via http://host:port
1211
+ // Spec: https://chromium.googlesource.com/chromium/src/+/main/content/browser/devtools/devtools_http_handler.cc
1212
+
1213
+ app
1214
+ .on(['GET', 'PUT'], '/json/version', (c) => {
1215
+ return c.json({
1216
+ Browser: `Tabwright/${VERSION}`,
1217
+ 'Protocol-Version': '1.3',
1218
+ webSocketDebuggerUrl: getCdpWsUrl(c),
1219
+ })
1220
+ })
1221
+ .on(['GET', 'PUT'], '/json/version/', (c) => {
1222
+ return c.json({
1223
+ Browser: `Tabwright/${VERSION}`,
1224
+ 'Protocol-Version': '1.3',
1225
+ webSocketDebuggerUrl: getCdpWsUrl(c),
1226
+ })
1227
+ })
1228
+ .on(['GET', 'PUT'], '/json/list', (c) => {
1229
+ const wsUrl = getCdpWsUrl(c)
1230
+ const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
1231
+ return c.json(
1232
+ Array.from(defaultTargets.values()).map((t) => ({
1233
+ id: t.targetId,
1234
+ type: t.targetInfo.type,
1235
+ title: t.targetInfo.title,
1236
+ description: t.targetInfo.title,
1237
+ url: t.targetInfo.url,
1238
+ webSocketDebuggerUrl: wsUrl,
1239
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
1240
+ })),
1241
+ )
1242
+ })
1243
+ .on(['GET', 'PUT'], '/json/list/', (c) => {
1244
+ const wsUrl = getCdpWsUrl(c)
1245
+ const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
1246
+ return c.json(
1247
+ Array.from(defaultTargets.values()).map((t) => ({
1248
+ id: t.targetId,
1249
+ type: t.targetInfo.type,
1250
+ title: t.targetInfo.title,
1251
+ description: t.targetInfo.title,
1252
+ url: t.targetInfo.url,
1253
+ webSocketDebuggerUrl: wsUrl,
1254
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
1255
+ })),
1256
+ )
1257
+ })
1258
+ .on(['GET', 'PUT'], '/json', (c) => {
1259
+ const wsUrl = getCdpWsUrl(c)
1260
+ const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
1261
+ return c.json(
1262
+ Array.from(defaultTargets.values()).map((t) => ({
1263
+ id: t.targetId,
1264
+ type: t.targetInfo.type,
1265
+ title: t.targetInfo.title,
1266
+ description: t.targetInfo.title,
1267
+ url: t.targetInfo.url,
1268
+ webSocketDebuggerUrl: wsUrl,
1269
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
1270
+ })),
1271
+ )
1272
+ })
1273
+ .on(['GET', 'PUT'], '/json/', (c) => {
1274
+ const wsUrl = getCdpWsUrl(c)
1275
+ const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
1276
+ return c.json(
1277
+ Array.from(defaultTargets.values()).map((t) => ({
1278
+ id: t.targetId,
1279
+ type: t.targetInfo.type,
1280
+ title: t.targetInfo.title,
1281
+ description: t.targetInfo.title,
1282
+ url: t.targetInfo.url,
1283
+ webSocketDebuggerUrl: wsUrl,
1284
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
1285
+ })),
1286
+ )
1287
+ })
1288
+
1289
+ app.post('/mcp-log', async (c) => {
1290
+ try {
1291
+ const { level, args } = await c.req.json()
1292
+ const logFn = (logger as any)?.[level] || logger?.log
1293
+ const prefix = pc.red(`[MCP] [${level.toUpperCase()}]`)
1294
+ logFn?.(prefix, ...args)
1295
+ return c.json({ ok: true })
1296
+ } catch {
1297
+ return c.json({ ok: false }, 400)
1298
+ }
1299
+ })
1300
+
1301
+ // Validate Origin header for WebSocket connections to prevent cross-origin attacks.
1302
+ // Browsers always send Origin header for WebSocket connections, but Node.js clients don't.
1303
+ // We only allow our specific extension IDs to prevent malicious websites or extensions
1304
+ // from connecting to the local WebSocket server.
1305
+ app.get(
1306
+ '/cdp/:clientId?',
1307
+ (c, next) => {
1308
+ const origin = c.req.header('origin')
1309
+
1310
+ // Validate Origin header if present (Node.js clients don't send it)
1311
+ if (origin) {
1312
+ if (origin.startsWith('chrome-extension://')) {
1313
+ const extensionId = origin.replace('chrome-extension://', '')
1314
+ if (!EXTENSION_IDS.includes(extensionId)) {
1315
+ logger?.log(pc.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`))
1316
+ return c.text('Forbidden', 403)
1317
+ }
1318
+ } else {
1319
+ logger?.log(pc.red(`Rejecting /cdp WebSocket from origin: ${origin}`))
1320
+ return c.text('Forbidden', 403)
1321
+ }
1322
+ }
1323
+
1324
+ if (token) {
1325
+ const url = new URL(c.req.url, 'http://localhost')
1326
+ const providedToken = url.searchParams.get('token')
1327
+ if (providedToken !== token) {
1328
+ return c.text('Unauthorized', 401)
1329
+ }
1330
+ }
1331
+ return next()
1332
+ },
1333
+ upgradeWebSocket((c) => {
1334
+ const clientId = c.req.param('clientId') || 'default'
1335
+ const url = new URL(c.req.url, 'http://localhost')
1336
+ const requestedExtensionId = url.searchParams.get('extensionId')
1337
+ // When extensionId is explicit, resolve directly. Otherwise use fallback which
1338
+ // handles single-extension and uniquely-active-extension cases (#52).
1339
+ const resolvedExtension = requestedExtensionId
1340
+ ? getExtensionConnection(requestedExtensionId)
1341
+ : getExtensionConnection(null, { allowFallback: true })
1342
+ const clientExtensionId = resolvedExtension?.id || null
1343
+
1344
+ const getBoundExtensionIdForClient = (): string | null => {
1345
+ const client = store.getState().playwrightClients.get(clientId)
1346
+ return client?.extensionId || null
1347
+ }
1348
+
1349
+ return {
1350
+ async onOpen(_event, ws) {
1351
+ if (store.getState().playwrightClients.has(clientId)) {
1352
+ logger?.log(pc.yellow(`Rejecting duplicate Playwright clientId: ${clientId}`))
1353
+ ws.close(4004, 'Duplicate Playwright clientId')
1354
+ return
1355
+ }
1356
+
1357
+ if (!clientExtensionId) {
1358
+ const reason = requestedExtensionId
1359
+ ? `Unknown extensionId: ${requestedExtensionId}`
1360
+ : 'Multiple extensions connected. Specify extensionId.'
1361
+ logger?.log(pc.yellow(`Rejecting Playwright client ${clientId}: ${reason}`))
1362
+ ws.close(4003, reason)
1363
+ return
1364
+ }
1365
+
1366
+ // Add client first so it can receive Target.attachedToTarget events
1367
+ store.setState((s) => {
1368
+ return relayState.addPlaywrightClient(s, { id: clientId, extensionId: clientExtensionId, ws })
1369
+ })
1370
+ const extensionConnection = getExtensionConnection(clientExtensionId)
1371
+ const targetCount = extensionConnection?.connectedTargets.size || 0
1372
+ logger?.log(
1373
+ pc.green(
1374
+ `Playwright client connected: ${clientId} (${store.getState().playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`,
1375
+ ),
1376
+ )
1377
+ },
1378
+
1379
+ async onMessage(event, ws) {
1380
+ let message: CDPCommand
1381
+
1382
+ try {
1383
+ message = JSON.parse(event.data.toString())
1384
+ } catch {
1385
+ return
1386
+ }
1387
+
1388
+ const { id, sessionId, method, params, source } = message
1389
+
1390
+ logCdpJson({
1391
+ timestamp: new Date().toISOString(),
1392
+ direction: 'from-playwright',
1393
+ clientId,
1394
+ message,
1395
+ })
1396
+
1397
+ logCdpMessage({
1398
+ direction: 'from-playwright',
1399
+ clientId,
1400
+ method,
1401
+ sessionId,
1402
+ id,
1403
+ })
1404
+
1405
+ emitter.emit('cdp:command', { clientId, command: message })
1406
+
1407
+ const boundExtensionId = getBoundExtensionIdForClient()
1408
+ const extensionConn = getExtensionConnection(boundExtensionId)
1409
+ if (!extensionConn) {
1410
+ sendToPlaywright({
1411
+ message: {
1412
+ id,
1413
+ sessionId,
1414
+ error: { message: 'Extension not connected' },
1415
+ },
1416
+ clientId,
1417
+ })
1418
+ return
1419
+ }
1420
+
1421
+ try {
1422
+ const result = await routeCdpCommand({
1423
+ extensionId: extensionConn.id,
1424
+ method,
1425
+ params,
1426
+ sessionId,
1427
+ source,
1428
+ })
1429
+
1430
+ if (method === 'Target.setAutoAttach' && !sessionId) {
1431
+ // Re-read state after async routeCdpCommand — targets may have changed
1432
+ const freshExt = store.getState().extensions.get(extensionConn.id)
1433
+ const freshTargets = freshExt?.connectedTargets || new Map()
1434
+ for (const target of freshTargets.values()) {
1435
+ // Skip restricted targets (extensions, chrome:// URLs, non-page types)
1436
+ if (isRestrictedTarget(target.targetInfo)) {
1437
+ continue
1438
+ }
1439
+ const attachedPayload = {
1440
+ method: 'Target.attachedToTarget',
1441
+ params: {
1442
+ sessionId: target.sessionId,
1443
+ targetInfo: {
1444
+ ...target.targetInfo,
1445
+ attached: true,
1446
+ },
1447
+ waitingForDebugger: false,
1448
+ },
1449
+ } satisfies CDPEventFor<'Target.attachedToTarget'>
1450
+ if (!target.targetInfo.url) {
1451
+ logger?.error(
1452
+ pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'),
1453
+ JSON.stringify(attachedPayload),
1454
+ )
1455
+ }
1456
+ logger?.log(
1457
+ pc.magenta('[Server] Target.attachedToTarget full payload:'),
1458
+ JSON.stringify(attachedPayload),
1459
+ )
1460
+ sendToPlaywright({
1461
+ message: attachedPayload,
1462
+ clientId,
1463
+ source: 'server',
1464
+ })
1465
+ }
1466
+ }
1467
+
1468
+ if (method === 'Target.setDiscoverTargets' && (params as Protocol.Target.SetDiscoverTargetsRequest)?.discover) {
1469
+ const freshExt2 = store.getState().extensions.get(extensionConn.id)
1470
+ const freshTargets2 = freshExt2?.connectedTargets || new Map()
1471
+ for (const target of freshTargets2.values()) {
1472
+ // Skip restricted targets (extensions, chrome:// URLs, non-page types)
1473
+ if (isRestrictedTarget(target.targetInfo)) {
1474
+ continue
1475
+ }
1476
+ const targetCreatedPayload = {
1477
+ method: 'Target.targetCreated',
1478
+ params: {
1479
+ targetInfo: {
1480
+ ...target.targetInfo,
1481
+ attached: true,
1482
+ },
1483
+ },
1484
+ } satisfies CDPEventFor<'Target.targetCreated'>
1485
+ if (!target.targetInfo.url) {
1486
+ logger?.error(
1487
+ pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'),
1488
+ JSON.stringify(targetCreatedPayload),
1489
+ )
1490
+ }
1491
+ logger?.log(
1492
+ pc.magenta('[Server] Target.targetCreated full payload:'),
1493
+ JSON.stringify(targetCreatedPayload),
1494
+ )
1495
+ sendToPlaywright({
1496
+ message: targetCreatedPayload,
1497
+ clientId,
1498
+ source: 'server',
1499
+ })
1500
+ }
1501
+ }
1502
+
1503
+ if (method === 'Target.attachToTarget') {
1504
+ const attachResponse = result as Protocol.Target.AttachToTargetResponse | undefined
1505
+ const attachRequestParams = params as Protocol.Target.AttachToTargetRequest | undefined
1506
+ if (attachResponse?.sessionId) {
1507
+ const freshExt3 = store.getState().extensions.get(extensionConn.id)
1508
+ const freshTargets3 = freshExt3?.connectedTargets || new Map()
1509
+ const target = Array.from(freshTargets3.values()).find((t) => {
1510
+ return t.targetId === attachRequestParams?.targetId
1511
+ })
1512
+ if (target) {
1513
+ const attachedPayload = {
1514
+ method: 'Target.attachedToTarget',
1515
+ params: {
1516
+ sessionId: attachResponse.sessionId,
1517
+ targetInfo: {
1518
+ ...target.targetInfo,
1519
+ attached: true,
1520
+ },
1521
+ waitingForDebugger: false,
1522
+ },
1523
+ } satisfies CDPEventFor<'Target.attachedToTarget'>
1524
+ if (!target.targetInfo.url) {
1525
+ logger?.error(
1526
+ pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'),
1527
+ JSON.stringify(attachedPayload),
1528
+ )
1529
+ }
1530
+ logger?.log(
1531
+ pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'),
1532
+ JSON.stringify(attachedPayload),
1533
+ )
1534
+ sendToPlaywright({
1535
+ message: attachedPayload,
1536
+ clientId,
1537
+ source: 'server',
1538
+ })
1539
+ }
1540
+ }
1541
+ }
1542
+
1543
+ const response: CDPResponseBase = { id, sessionId, result }
1544
+ sendToPlaywright({ message: response, clientId })
1545
+ emitter.emit('cdp:response', { clientId, response, command: message })
1546
+ } catch (e) {
1547
+ logger?.error('Error handling CDP command:', method, params, e)
1548
+ const errorResponse: CDPResponseBase = {
1549
+ id,
1550
+ sessionId,
1551
+ error: { message: (e as Error).message },
1552
+ }
1553
+ sendToPlaywright({ message: errorResponse, clientId })
1554
+ emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
1555
+ }
1556
+ },
1557
+
1558
+ onClose() {
1559
+ store.setState((s) => relayState.removePlaywrightClient(s, { clientId }))
1560
+ logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${store.getState().playwrightClients.size} remaining)`))
1561
+ },
1562
+
1563
+ onError(event) {
1564
+ logger?.error(`Playwright WebSocket error [${clientId}]:`, event)
1565
+ },
1566
+ }
1567
+ }),
1568
+ )
1569
+
1570
+ const getExtensionInfoFromRequest = (c: {
1571
+ req: { query: (name: string) => string | undefined }
1572
+ }): relayState.ExtensionInfo => {
1573
+ const browser = c.req.query('browser')
1574
+ const email = c.req.query('email')
1575
+ const id = c.req.query('id')
1576
+ const installId = c.req.query('installId')
1577
+ const version = c.req.query('v')
1578
+ const protocolVersionValue = c.req.query('protocolVersion')
1579
+ const protocolVersion = protocolVersionValue && /^\d+$/.test(protocolVersionValue)
1580
+ ? Number(protocolVersionValue)
1581
+ : undefined
1582
+ const featureValue = c.req.query('features')
1583
+ const features = parseExtensionFeatures(featureValue)
1584
+ return {
1585
+ browser: browser || undefined,
1586
+ email: email || undefined,
1587
+ id: id || undefined,
1588
+ installId: installId || undefined,
1589
+ version: version || undefined,
1590
+ protocolVersion,
1591
+ features,
1592
+ }
1593
+ }
1594
+
1595
+ app.get(
1596
+ '/extension',
1597
+ (c, next) => {
1598
+ // 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
1599
+ // This prevents attackers on the network from hijacking the browser session
1600
+ // even if the server is exposed via 0.0.0.0.
1601
+ const info = getConnInfo(c)
1602
+ const remoteAddress = info.remote.address
1603
+ const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1'
1604
+
1605
+ if (!isLocalhost) {
1606
+ logger?.log(pc.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`))
1607
+ return c.text('Forbidden - Extension must be local', 403)
1608
+ }
1609
+
1610
+ // 2. Origin Validation: Prevent browser-based attacks (CSRF).
1611
+ // Browsers cannot spoof the Origin header, so this ensures the connection
1612
+ // is coming from our specific Chrome Extension, not a malicious website.
1613
+ const origin = c.req.header('origin')
1614
+ if (!origin || !origin.startsWith('chrome-extension://')) {
1615
+ logger?.log(
1616
+ pc.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`),
1617
+ )
1618
+ return c.text('Forbidden', 403)
1619
+ }
1620
+
1621
+ const extensionId = origin.replace('chrome-extension://', '')
1622
+ if (!EXTENSION_IDS.includes(extensionId)) {
1623
+ logger?.log(pc.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`))
1624
+ return c.text('Forbidden', 403)
1625
+ }
1626
+
1627
+ return next()
1628
+ },
1629
+ upgradeWebSocket((c) => {
1630
+ const incomingExtensionInfo = getExtensionInfoFromRequest(c)
1631
+ const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
1632
+ return {
1633
+ onOpen(_event, ws) {
1634
+ const stableKey = relayState.buildStableExtensionKey(incomingExtensionInfo, connectionId)
1635
+
1636
+ // Check for existing connection with same stableKey and close it
1637
+ const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey)
1638
+ if (existingExt && existingExt.id !== connectionId) {
1639
+ logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingExt.id} -> ${connectionId})`))
1640
+ if (existingExt.ws) {
1641
+ existingExt.ws.close(4001, 'Extension Replaced')
1642
+ }
1643
+ }
1644
+
1645
+ // State transition: add extension with ws handle included.
1646
+ // Existing same-stableKey entry stays until old socket onClose.
1647
+ store.setState((s) => {
1648
+ return relayState.addExtension(s, { id: connectionId, info: incomingExtensionInfo, stableKey, ws })
1649
+ })
1650
+
1651
+ startExtensionPing(connectionId)
1652
+ logger?.log(`Extension connected (${connectionId})`)
1653
+ },
1654
+
1655
+ async onMessage(event, ws) {
1656
+ const ext = store.getState().extensions.get(connectionId)
1657
+ if (!ext) {
1658
+ ws.close(1000, 'Extension not registered')
1659
+ return
1660
+ }
1661
+ // Older extension builds may still send legacy recording chunks. Ignore binary frames
1662
+ // instead of breaking the WS connection.
1663
+ if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
1664
+ return
1665
+ }
1666
+
1667
+ let message: ExtensionMessage
1668
+
1669
+ try {
1670
+ message = JSON.parse(event.data.toString())
1671
+ } catch {
1672
+ ws.close(1000, 'Invalid JSON')
1673
+ return
1674
+ }
1675
+
1676
+ if (message.id !== undefined) {
1677
+ const pending = (() => {
1678
+ let pendingRequest: relayState.ExtensionPendingRequest | null = null
1679
+
1680
+ store.setState((s) => {
1681
+ const extensionEntry = s.extensions.get(connectionId)
1682
+ if (!extensionEntry) {
1683
+ return s
1684
+ }
1685
+
1686
+ const nextPendingRequest = extensionEntry.pendingRequests.get(message.id)
1687
+ if (!nextPendingRequest) {
1688
+ return s
1689
+ }
1690
+
1691
+ pendingRequest = nextPendingRequest
1692
+ return relayState.removeExtensionPendingRequest(s, {
1693
+ extensionId: connectionId,
1694
+ requestId: message.id,
1695
+ })
1696
+ })
1697
+
1698
+ return pendingRequest
1699
+ })() as relayState.ExtensionPendingRequest | null
1700
+
1701
+ if (!pending) {
1702
+ logger?.log('Unexpected response with id:', message.id)
1703
+ return
1704
+ }
1705
+
1706
+ if (message.error) {
1707
+ pending.reject(new Error(message.error))
1708
+ } else {
1709
+ pending.resolve(message.result)
1710
+ }
1711
+ } else if (message.method === 'pong') {
1712
+ store.setState((s) => {
1713
+ return relayState.updateExtensionIO(s, {
1714
+ extensionId: connectionId,
1715
+ lastPongAt: Date.now(),
1716
+ })
1717
+ })
1718
+ } else if (message.method === 'log') {
1719
+ const { level, args } = message.params
1720
+ const logFn = (logger as Record<string, unknown>)?.[level] as ((...args: unknown[]) => void) | undefined
1721
+ const logFunc = logFn || logger?.log
1722
+ const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`)
1723
+ logFunc?.(prefix, ...args)
1724
+ } else if (message.method === 'rrwebRecordingData') {
1725
+ const relay = getRrwebRecordingRelay(connectionId)
1726
+ if (relay) {
1727
+ relay.handleRrwebRecordingData(message as RrwebRecordingDataMessage)
1728
+ }
1729
+ } else if (message.method === 'rrwebRecordingCancelled') {
1730
+ const relay = getRrwebRecordingRelay(connectionId)
1731
+ if (relay) {
1732
+ relay.handleRrwebRecordingCancelled(message as RrwebRecordingCancelledMessage)
1733
+ }
1734
+ } else if (message.method === 'toolbarRecordingRequest') {
1735
+ const toolbarMessage = message as ToolbarRecordingRequestMessage
1736
+ const result = await handleToolbarRecordingRequest(connectionId, toolbarMessage)
1737
+ logger?.log(
1738
+ pc.blue(
1739
+ `Toolbar recording response: requestId=${toolbarMessage.params.requestId} success=${result.success} isRecording=${result.isRecording ?? 'unknown'}`,
1740
+ ),
1741
+ )
1742
+ ws.send(
1743
+ JSON.stringify({
1744
+ method: 'toolbarRecordingResponse',
1745
+ params: {
1746
+ requestId: toolbarMessage.params.requestId,
1747
+ result,
1748
+ },
1749
+ }),
1750
+ )
1751
+ } else {
1752
+ const extensionEvent = message as ExtensionEventMessage
1753
+
1754
+ if (extensionEvent.method !== 'forwardCDPEvent') {
1755
+ return
1756
+ }
1757
+
1758
+ const { method, params, sessionId } = extensionEvent.params
1759
+
1760
+ logCdpJson({
1761
+ timestamp: new Date().toISOString(),
1762
+ direction: 'from-extension',
1763
+ message: { method, params, sessionId },
1764
+ })
1765
+
1766
+ logCdpMessage({
1767
+ direction: 'from-extension',
1768
+ method,
1769
+ sessionId,
1770
+ params,
1771
+ })
1772
+
1773
+ const cdpEvent: CDPEventBase = { method, sessionId, params }
1774
+ emitter.emit('cdp:event', { event: cdpEvent, sessionId })
1775
+
1776
+ maybeEmitBrowserDownloadCompatEvent({ method, params, extensionId: connectionId })
1777
+
1778
+ if (method === 'Target.attachedToTarget') {
1779
+ const targetParams = params as Protocol.Target.AttachedToTargetEvent
1780
+ const incomingSessionId = sessionId
1781
+ const iframeParentFrameId = targetParams.targetInfo.parentFrameId
1782
+ // Read current extension state for iframe parent lookup
1783
+ const currentExtState = store.getState().extensions.get(connectionId)
1784
+ const iframeOwnerSessionId =
1785
+ targetParams.targetInfo.type === 'iframe' && iframeParentFrameId && currentExtState
1786
+ ? getPageTargetForFrameId({ extensionState: currentExtState, frameId: iframeParentFrameId })?.sessionId
1787
+ : undefined
1788
+
1789
+ // Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.)
1790
+ if (isRestrictedTarget(targetParams.targetInfo)) {
1791
+ if (targetParams.waitingForDebugger && targetParams.sessionId) {
1792
+ void sendToExtension({
1793
+ extensionId: connectionId,
1794
+ method: 'forwardCDPCommand',
1795
+ params: {
1796
+ sessionId: targetParams.sessionId,
1797
+ method: 'Runtime.runIfWaitingForDebugger',
1798
+ params: {},
1799
+ source: 'server',
1800
+ },
1801
+ }).catch((error) => {
1802
+ const msg = error instanceof Error ? error.message : String(error)
1803
+ logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), msg)
1804
+ })
1805
+ }
1806
+ logger?.log(
1807
+ pc.gray(
1808
+ `[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`,
1809
+ ),
1810
+ )
1811
+ return
1812
+ }
1813
+
1814
+ if (!targetParams.targetInfo.url) {
1815
+ logger?.error(
1816
+ pc.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'),
1817
+ JSON.stringify({ method, params: targetParams, sessionId }),
1818
+ )
1819
+ }
1820
+ logger?.log(
1821
+ pc.yellow('[Extension] Target.attachedToTarget full payload:'),
1822
+ JSON.stringify({ method, params: targetParams, sessionId }),
1823
+ )
1824
+
1825
+ // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
1826
+ const alreadyConnected = currentExtState?.connectedTargets.has(targetParams.sessionId) ?? false
1827
+
1828
+ // State transition: add/update target
1829
+ store.setState((s) =>
1830
+ relayState.addTarget(s, {
1831
+ extensionId: connectionId,
1832
+ sessionId: targetParams.sessionId,
1833
+ targetId: targetParams.targetInfo.targetId,
1834
+ targetInfo: targetParams.targetInfo,
1835
+ }),
1836
+ )
1837
+
1838
+ const cachedDownloadBehavior = extensionDownloadBehavior.get(connectionId)
1839
+ if (cachedDownloadBehavior && targetParams.targetInfo.type === 'page') {
1840
+ void applyDownloadBehaviorToTargets({
1841
+ extensionId: connectionId,
1842
+ behavior: cachedDownloadBehavior,
1843
+ targetSessionIds: [targetParams.sessionId],
1844
+ })
1845
+ }
1846
+
1847
+ // Only forward to Playwright if this is a new target to avoid duplicates
1848
+ if (!alreadyConnected) {
1849
+ sendToPlaywright({
1850
+ message: {
1851
+ // Iframe targets must be routed to the parent page sessionId so Playwright attaches them under the right page.
1852
+ // - iframeOwnerSessionId: derived parent session via parentFrameId -> page sessionId (frameId tracking).
1853
+ // - incomingSessionId: extension event sessionId for the parent tab.
1854
+ // The frameId mapping is racy: Target.attachedToTarget can arrive before Page.frameAttached/Page.frameNavigated populate frameIds.
1855
+ // When iframeOwnerSessionId is missing we must fall back to incomingSessionId, otherwise Playwright receives the attach on the root
1856
+ // session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations.
1857
+ sessionId: iframeOwnerSessionId ?? incomingSessionId,
1858
+ method: 'Target.attachedToTarget',
1859
+ params: targetParams,
1860
+ } as CDPEventBase,
1861
+ source: 'extension',
1862
+ extensionId: connectionId,
1863
+ })
1864
+ }
1865
+ } else if (method === 'Target.detachedFromTarget') {
1866
+ const detachParams = params as Protocol.Target.DetachedFromTargetEvent
1867
+ store.setState((s) =>
1868
+ relayState.removeTarget(s, { extensionId: connectionId, sessionId: detachParams.sessionId }),
1869
+ )
1870
+
1871
+ sendToPlaywright({
1872
+ message: {
1873
+ method: 'Target.detachedFromTarget',
1874
+ params: detachParams,
1875
+ } as CDPEventBase,
1876
+ source: 'extension',
1877
+ extensionId: connectionId,
1878
+ })
1879
+ } else if (method === 'Target.targetCrashed') {
1880
+ const crashParams = params as Protocol.Target.TargetCrashedEvent
1881
+ store.setState((s) =>
1882
+ relayState.removeTargetByCrash(s, { extensionId: connectionId, targetId: crashParams.targetId }),
1883
+ )
1884
+ logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId)
1885
+
1886
+ sendToPlaywright({
1887
+ message: {
1888
+ method: 'Target.targetCrashed',
1889
+ params: crashParams,
1890
+ } as CDPEventBase,
1891
+ source: 'extension',
1892
+ extensionId: connectionId,
1893
+ })
1894
+ } else if (method === 'Target.targetInfoChanged') {
1895
+ const infoParams = params as Protocol.Target.TargetInfoChangedEvent
1896
+ store.setState((s) =>
1897
+ relayState.updateTargetInfo(s, { extensionId: connectionId, targetInfo: infoParams.targetInfo }),
1898
+ )
1899
+
1900
+ sendToPlaywright({
1901
+ message: {
1902
+ method: 'Target.targetInfoChanged',
1903
+ params: infoParams,
1904
+ } as CDPEventBase,
1905
+ source: 'extension',
1906
+ extensionId: connectionId,
1907
+ })
1908
+ } else if (method === 'Page.frameAttached') {
1909
+ const frameParams = params as Protocol.Page.FrameAttachedEvent
1910
+ if (sessionId) {
1911
+ store.setState((s) =>
1912
+ relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frameId }),
1913
+ )
1914
+ }
1915
+
1916
+ sendToPlaywright({
1917
+ message: {
1918
+ sessionId,
1919
+ method,
1920
+ params,
1921
+ } as CDPEventBase,
1922
+ source: 'extension',
1923
+ extensionId: connectionId,
1924
+ })
1925
+ } else if (method === 'Page.frameDetached') {
1926
+ const frameParams = params as Protocol.Page.FrameDetachedEvent
1927
+ store.setState((s) =>
1928
+ relayState.removeFrameId(s, { extensionId: connectionId, frameId: frameParams.frameId }),
1929
+ )
1930
+
1931
+ sendToPlaywright({
1932
+ message: {
1933
+ sessionId,
1934
+ method,
1935
+ params,
1936
+ } as CDPEventBase,
1937
+ source: 'extension',
1938
+ extensionId: connectionId,
1939
+ })
1940
+ } else if (method === 'Page.frameNavigated') {
1941
+ const frameParams = params as Protocol.Page.FrameNavigatedEvent
1942
+ if (sessionId) {
1943
+ store.setState((s) =>
1944
+ relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frame.id }),
1945
+ )
1946
+ }
1947
+ if (!frameParams.frame.parentId && sessionId) {
1948
+ store.setState((s) =>
1949
+ relayState.updateTargetUrl(s, {
1950
+ extensionId: connectionId,
1951
+ sessionId,
1952
+ url: frameParams.frame.url,
1953
+ title: frameParams.frame.name || undefined,
1954
+ }),
1955
+ )
1956
+ logger?.log(
1957
+ pc.magenta('[Server] Updated target URL from Page.frameNavigated:'),
1958
+ frameParams.frame.url,
1959
+ )
1960
+ }
1961
+
1962
+ sendToPlaywright({
1963
+ message: {
1964
+ sessionId,
1965
+ method,
1966
+ params,
1967
+ } as CDPEventBase,
1968
+ source: 'extension',
1969
+ extensionId: connectionId,
1970
+ })
1971
+ } else if (method === 'Page.navigatedWithinDocument') {
1972
+ const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent
1973
+ if (sessionId) {
1974
+ store.setState((s) =>
1975
+ relayState.updateTargetUrl(s, { extensionId: connectionId, sessionId, url: navParams.url }),
1976
+ )
1977
+ logger?.log(
1978
+ pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'),
1979
+ navParams.url,
1980
+ )
1981
+ }
1982
+
1983
+ sendToPlaywright({
1984
+ message: {
1985
+ sessionId,
1986
+ method,
1987
+ params,
1988
+ } as CDPEventBase,
1989
+ source: 'extension',
1990
+ extensionId: connectionId,
1991
+ })
1992
+ } else {
1993
+ sendToPlaywright({
1994
+ message: {
1995
+ sessionId,
1996
+ method,
1997
+ params,
1998
+ } as CDPEventBase,
1999
+ source: 'extension',
2000
+ extensionId: connectionId,
2001
+ })
2002
+ }
2003
+ }
2004
+ },
2005
+
2006
+ onClose(event) {
2007
+ logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`)
2008
+
2009
+ // Reject all pending I/O requests (state cleanup happens in removeExtension below)
2010
+ const closingExt = store.getState().extensions.get(connectionId)
2011
+ if (closingExt) {
2012
+ stopExtensionPing(connectionId)
2013
+ for (const pending of closingExt.pendingRequests.values()) {
2014
+ pending.reject(new Error('Extension connection closed'))
2015
+ }
2016
+ }
2017
+
2018
+ const currentRelayState = store.getState()
2019
+ const closingExtension = currentRelayState.extensions.get(connectionId)
2020
+ const successorCandidates = closingExtension
2021
+ ? Array.from(currentRelayState.extensions.values())
2022
+ .reverse()
2023
+ .filter((ext) => {
2024
+ return ext.id !== connectionId && ext.stableKey === closingExtension.stableKey && Boolean(ext.ws)
2025
+ })
2026
+ : []
2027
+ const successorExtension = closingExtension
2028
+ ? successorCandidates[0]
2029
+ : undefined
2030
+
2031
+ if (successorExtension) {
2032
+ logger?.log(
2033
+ pc.yellow(
2034
+ `Rebinding clients from ${connectionId} to ${successorExtension.id} (stableKey: ${successorExtension.stableKey})`,
2035
+ ),
2036
+ )
2037
+ store.setState((s) => {
2038
+ return relayState.rebindClientsToExtension(s, {
2039
+ fromExtensionId: connectionId,
2040
+ toExtensionId: successorExtension.id,
2041
+ })
2042
+ })
2043
+ }
2044
+
2045
+ // Close playwright clients bound to this extension when no successor exists.
2046
+ if (!successorExtension) {
2047
+ const { playwrightClients } = store.getState()
2048
+ for (const client of playwrightClients.values()) {
2049
+ if (client.extensionId === connectionId) {
2050
+ client.ws.close(1000, 'Extension disconnected')
2051
+ }
2052
+ }
2053
+ }
2054
+
2055
+ // State transition: remove extension + its bound clients atomically
2056
+ store.setState((s) => relayState.removeExtension(s, { extensionId: connectionId }))
2057
+ },
2058
+
2059
+ onError(event) {
2060
+ logger?.error('Extension WebSocket error:', event)
2061
+ },
2062
+ }
2063
+ }),
2064
+ )
2065
+
2066
+ // ============================================================================
2067
+ // CLI Execute Endpoints - For stateful code execution via CLI
2068
+ // ============================================================================
2069
+
2070
+ // Session counter for suggesting next session number
2071
+ let nextSessionNumber = 1
2072
+
2073
+ // Lazy-load ExecutorManager to avoid circular imports and only when needed
2074
+ let executorManager: import('./executor.js').ExecutorManager | null = null
2075
+
2076
+ const getExecutorManager = async () => {
2077
+ if (!executorManager) {
2078
+ const { ExecutorManager } = await import('./executor.js')
2079
+ // Pass config instead of URL so executor can generate unique client IDs for each connection
2080
+ executorManager = new ExecutorManager({
2081
+ cdpConfig: { host: '127.0.0.1', port, token },
2082
+ logger: logger || { log: console.error, error: console.error },
2083
+ })
2084
+ }
2085
+ return executorManager
2086
+ }
2087
+
2088
+ // ============================================================================
2089
+ // Security middleware for privileged HTTP routes (/cli/*, /recording/*, /mcp-log)
2090
+ //
2091
+ // CORS alone does NOT prevent cross-origin POST attacks. Browsers skip the
2092
+ // preflight for "simple" requests (POST + Content-Type: text/plain), so a
2093
+ // malicious website can fire-and-forget a POST to localhost:19988/cli/execute
2094
+ // and the code executes before CORS even enters the picture.
2095
+ //
2096
+ // Three layers of defense:
2097
+ // 1. Sec-Fetch-Site: browsers set this forbidden header on every request.
2098
+ // If present and not "same-origin"/"none", it's a cross-origin browser
2099
+ // request → reject. Node.js clients don't send it → unaffected.
2100
+ // 2. Content-Type must be application/json on POST. This forces a CORS
2101
+ // preflight as a fallback, which our CORS policy already blocks.
2102
+ // 3. When token mode is enabled (remote access), require the token on EVERY
2103
+ // request, including loopback. Tunnel agents (traforo, ngrok, cloudflared)
2104
+ // forward public traffic from 127.0.0.1, so a loopback bypass would be
2105
+ // a full auth bypass. In-process callers attach the token themselves
2106
+ // via PLAYWRITER_TOKEN env (set by the `serve` command at startup).
2107
+ // ============================================================================
2108
+ const privilegedRouteMiddleware = async (
2109
+ c: Parameters<Parameters<typeof app.use>[1]>[0],
2110
+ next: () => Promise<void>,
2111
+ ) => {
2112
+ // Block cross-origin browser requests via Sec-Fetch-Site header.
2113
+ // Browsers always set this forbidden header; it cannot be spoofed.
2114
+ // Non-browser clients (Node.js, curl, MCP) don't send it.
2115
+ const secFetchSite = c.req.header('sec-fetch-site')
2116
+ if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
2117
+ logger?.log(pc.red(`Rejecting ${c.req.path}: cross-origin browser request (Sec-Fetch-Site: ${secFetchSite})`))
2118
+ return c.text('Forbidden - Cross-origin requests not allowed', 403)
2119
+ }
2120
+
2121
+ // Require application/json on POST to force CORS preflight as backup defense.
2122
+ // A text/plain POST is a "simple request" that skips preflight entirely.
2123
+ if (c.req.method === 'POST') {
2124
+ const contentType = c.req.header('content-type') || ''
2125
+ if (!contentType.includes('application/json')) {
2126
+ logger?.log(pc.red(`Rejecting ${c.req.path}: Content-Type must be application/json, got: ${contentType}`))
2127
+ return c.text('Content-Type must be application/json', 415)
2128
+ }
2129
+ }
2130
+
2131
+ // When token mode is enabled (remote/serve mode), require authentication
2132
+ // on EVERY request, including loopback. Earlier versions bypassed the
2133
+ // check for 127.0.0.1/::1 to spare in-process callers, but that's unsafe:
2134
+ // when the relay is fronted by a tunnel agent (traforo, ngrok, cloudflared,
2135
+ // etc.) running as a local process, every public request reaches the relay
2136
+ // from 127.0.0.1 and would skip auth. In-process callers must instead
2137
+ // attach the token themselves — they read PLAYWRITER_TOKEN from env, which
2138
+ // the `serve` command sets at startup.
2139
+ if (token) {
2140
+ const authHeader = c.req.header('authorization') || ''
2141
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null
2142
+ const url = new URL(c.req.url, 'http://localhost')
2143
+ const queryToken = url.searchParams.get('token')
2144
+ if (bearerToken !== token && queryToken !== token) {
2145
+ logger?.log(pc.red(`Rejecting ${c.req.path}: invalid or missing token`))
2146
+ return c.text('Unauthorized', 401)
2147
+ }
2148
+ }
2149
+
2150
+ return next()
2151
+ }
2152
+
2153
+ app.use('/cli/*', privilegedRouteMiddleware)
2154
+ app.use('/recording/*', privilegedRouteMiddleware)
2155
+ app.use('/rrweb-recording/*', privilegedRouteMiddleware)
2156
+ app.use('/mcp-log', privilegedRouteMiddleware)
2157
+
2158
+ const reviewRouteMiddleware = async (
2159
+ c: Parameters<Parameters<typeof app.use>[1]>[0],
2160
+ next: () => Promise<void>,
2161
+ ) => {
2162
+ if (c.req.method === 'POST') {
2163
+ const contentType = c.req.header('content-type') || ''
2164
+ if (!contentType.includes('application/json')) {
2165
+ logger?.log(pc.red(`Rejecting ${c.req.path}: Content-Type must be application/json, got: ${contentType}`))
2166
+ return c.text('Content-Type must be application/json', 415)
2167
+ }
2168
+ }
2169
+
2170
+ const origin = c.req.header('origin')
2171
+ if (!origin) {
2172
+ const secFetchSite = c.req.header('sec-fetch-site')
2173
+ if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
2174
+ logger?.log(pc.red(`Rejecting ${c.req.path}: cross-origin browser request (Sec-Fetch-Site: ${secFetchSite})`))
2175
+ return c.text('Forbidden - Cross-origin requests not allowed', 403)
2176
+ }
2177
+ return next()
2178
+ }
2179
+
2180
+ if (!origin.startsWith('chrome-extension://')) {
2181
+ logger?.log(pc.red(`Rejecting ${c.req.path}: origin must be Tabwright extension, got: ${origin}`))
2182
+ return c.text('Forbidden', 403)
2183
+ }
2184
+
2185
+ const extensionId = origin.replace('chrome-extension://', '')
2186
+ if (!EXTENSION_IDS.includes(extensionId)) {
2187
+ logger?.log(pc.red(`Rejecting ${c.req.path}: unknown extension origin ${extensionId}`))
2188
+ return c.text('Forbidden', 403)
2189
+ }
2190
+
2191
+ return next()
2192
+ }
2193
+
2194
+ app.use('/rrweb-recordings', reviewRouteMiddleware)
2195
+ app.use('/rrweb-recordings/*', reviewRouteMiddleware)
2196
+ app.use('/capabilities', reviewRouteMiddleware)
2197
+ app.use('/capabilities/*', reviewRouteMiddleware)
2198
+
2199
+ app.post('/cli/execute', async (c) => {
2200
+ try {
2201
+ const body = (await c.req.json()) as {
2202
+ sessionId: string | number
2203
+ code: string
2204
+ timeout?: number
2205
+ includeStructuredResult?: boolean
2206
+ }
2207
+ const sessionId = normalizeSessionId(body.sessionId)
2208
+ const { code, timeout = 10000 } = body
2209
+
2210
+ if (!sessionId || !code) {
2211
+ return c.json({ error: 'sessionId and code are required' }, 400)
2212
+ }
2213
+
2214
+ const manager = await getExecutorManager()
2215
+ const existingExecutor = manager.getSession(sessionId)
2216
+ if (!existingExecutor) {
2217
+ return c.json(
2218
+ { text: `Session ${sessionId} not found. Run 'tabwright session new' first.`, images: [], screenshots: [], isError: true },
2219
+ 404,
2220
+ )
2221
+ }
2222
+ // Touch cloud session activity tracking if this session is cloud-backed
2223
+ const cloudTracking = cloudSessionTracking.get(sessionId)
2224
+ if (cloudTracking) {
2225
+ cloudTracking.lastActivityAt = Date.now()
2226
+ cloudTracking.activeExecutions++
2227
+ }
2228
+
2229
+ let result: Awaited<ReturnType<typeof existingExecutor.execute>>
2230
+ try {
2231
+ result = await existingExecutor.execute(code, timeout, {
2232
+ includeStructuredResult: body.includeStructuredResult === true,
2233
+ })
2234
+ } finally {
2235
+ if (cloudTracking) {
2236
+ cloudTracking.activeExecutions--
2237
+ cloudTracking.lastActivityAt = Date.now()
2238
+ }
2239
+ }
2240
+
2241
+ // Use the cloudTracking snapshot captured before execute (not a fresh
2242
+ // map lookup) so long-running executes that outlive idle cleanup still
2243
+ // report isCloud correctly.
2244
+ return c.json({ ...result, isCloud: Boolean(cloudTracking) })
2245
+ } catch (error: any) {
2246
+ logger?.error('Execute endpoint error:', error)
2247
+ return c.json({ text: `Server error: ${error.message}`, images: [], screenshots: [], isError: true }, 500)
2248
+ }
2249
+ })
2250
+
2251
+ app.post('/cli/reset', async (c) => {
2252
+ try {
2253
+ const body = (await c.req.json()) as { sessionId: string | number }
2254
+ const sessionId = normalizeSessionId(body.sessionId)
2255
+
2256
+ if (!sessionId) {
2257
+ return c.json({ error: 'sessionId is required' }, 400)
2258
+ }
2259
+
2260
+ const manager = await getExecutorManager()
2261
+ const existingExecutor = manager.getSession(sessionId)
2262
+ if (!existingExecutor) {
2263
+ return c.json({ error: `Session ${sessionId} not found. Run 'tabwright session new' first.` }, 404)
2264
+ }
2265
+ const { page, context } = await existingExecutor.reset()
2266
+
2267
+ return c.json({
2268
+ success: true,
2269
+ pageUrl: page.url(),
2270
+ pagesCount: context.pages().length,
2271
+ })
2272
+ } catch (error: any) {
2273
+ logger?.error('Reset endpoint error:', error)
2274
+ return c.json({ error: error.message }, 500)
2275
+ }
2276
+ })
2277
+
2278
+ app.get('/cli/sessions', async (c) => {
2279
+ const manager = await getExecutorManager()
2280
+ return c.json({ sessions: manager.listSessions() })
2281
+ })
2282
+
2283
+ app.get('/cli/session/suggest', (c) => {
2284
+ return c.json({ next: nextSessionNumber })
2285
+ })
2286
+
2287
+ app.get('/rrweb-recordings', (c) => {
2288
+ const rawLimit = Number(c.req.query('limit') || 50)
2289
+ const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 50
2290
+ return c.json({ recordings: listSavedRrwebRecordings({ limit }) })
2291
+ })
2292
+
2293
+ app.get('/rrweb-recordings/:id', (c) => {
2294
+ const recording = getSavedRrwebRecording(c.req.param('id'))
2295
+ if (!recording) {
2296
+ return c.json({ error: 'rrweb recording not found' }, 404)
2297
+ }
2298
+ return c.json({ recording })
2299
+ })
2300
+
2301
+ app.get('/rrweb-recordings/:id/events', (c) => {
2302
+ const result = getSavedRrwebRecordingWithEvents(c.req.param('id'))
2303
+ if (!result) {
2304
+ return c.json({ error: 'rrweb recording not found' }, 404)
2305
+ }
2306
+ return c.json(result)
2307
+ })
2308
+
2309
+ app.get('/capabilities', (c) => {
2310
+ return c.json(listCapabilityOptions({ cwd: process.cwd() }))
2311
+ })
2312
+
2313
+ app.get('/capabilities/:id', (c) => {
2314
+ const result = getCapabilityOptionsDetail({ cwd: process.cwd(), id: c.req.param('id') })
2315
+ if (!result) {
2316
+ return c.json({ error: 'capability not found' }, 404)
2317
+ }
2318
+ return c.json(result)
2319
+ })
2320
+
2321
+ app.post('/capabilities/:id/auth/refresh', async (c) => {
2322
+ const id = c.req.param('id')
2323
+ const body = (await c.req.json().catch(() => ({}))) as { installId?: unknown }
2324
+ const installId = typeof body.installId === 'string' ? body.installId : undefined
2325
+ const capability = (() => {
2326
+ try {
2327
+ return requireCapability({ id, cwd: process.cwd() })
2328
+ } catch {
2329
+ return null
2330
+ }
2331
+ })()
2332
+ if (!capability) {
2333
+ return c.json({ error: 'capability not found' }, 404)
2334
+ }
2335
+ if (capability.manifest.auth.type !== 'cookie' || capability.manifest.auth.refresh !== 'from-browser') {
2336
+ return c.json({ error: 'capability does not support browser cookie authentication' }, 400)
2337
+ }
2338
+
2339
+ const connectedExtensions = Array.from(store.getState().extensions.values()).filter((extension) => {
2340
+ return Boolean(extension.ws)
2341
+ })
2342
+ const matchingExtensions = installId
2343
+ ? connectedExtensions.filter((extension) => {
2344
+ return extension.info.installId === installId
2345
+ })
2346
+ : connectedExtensions
2347
+ if (matchingExtensions.length === 0) {
2348
+ return c.json(
2349
+ { code: 'browser_not_connected', error: 'current browser profile is not connected to Tabwright' },
2350
+ 409,
2351
+ )
2352
+ }
2353
+ if (matchingExtensions.length > 1) {
2354
+ return c.json(
2355
+ {
2356
+ code: 'multiple_browser_profiles',
2357
+ error: 'multiple browser profiles are connected; refresh from the profile that opened this page',
2358
+ },
2359
+ 409,
2360
+ )
2361
+ }
2362
+ const extension = matchingExtensions[0]
2363
+ if (!extension) {
2364
+ return c.json(
2365
+ { code: 'browser_not_connected', error: 'current browser profile is not connected to Tabwright' },
2366
+ 409,
2367
+ )
2368
+ }
2369
+ const authSession = await getCapabilityAuthSession({ extensionId: extension.id })
2370
+ if (!authSession.ok) {
2371
+ return c.json({ code: authSession.code, error: authSession.error }, 409)
2372
+ }
2373
+
2374
+ try {
2375
+ // Cookie values travel only over the existing extension/Relay CDP channel and are never returned to Options.
2376
+ const rawCookies = await routeCdpCommand({
2377
+ extensionId: extension.id,
2378
+ method: 'Network.getCookies',
2379
+ params: { urls: capability.manifest.auth.browserUrls },
2380
+ sessionId: authSession.sessionId,
2381
+ source: 'playwriter',
2382
+ })
2383
+ const cookies = parseCapabilityAuthCookies(rawCookies)
2384
+ const result = refreshCapabilityAuthFromCookies({
2385
+ id,
2386
+ cookies,
2387
+ cwd: process.cwd(),
2388
+ browserKey: extension.stableKey,
2389
+ })
2390
+ return c.json({
2391
+ capability: id,
2392
+ saved: result.saved,
2393
+ cookieCount: result.cookieCount,
2394
+ cookieNames: result.cookieNames,
2395
+ urls: result.urls,
2396
+ expiresAt: result.expiresAt,
2397
+ authState: getCapabilityAuthState({ capability: result.capability }),
2398
+ })
2399
+ } catch (error: unknown) {
2400
+ const message = error instanceof Error ? error.message : String(error)
2401
+ return c.json({ error: message }, 400)
2402
+ } finally {
2403
+ if (authSession.temporaryTargetId) {
2404
+ try {
2405
+ await routeCdpCommand({
2406
+ extensionId: extension.id,
2407
+ method: 'Target.closeTarget',
2408
+ params: { targetId: authSession.temporaryTargetId },
2409
+ source: 'playwriter',
2410
+ })
2411
+ } catch (error: unknown) {
2412
+ logger?.error('Failed to close temporary capability auth tab:', error)
2413
+ }
2414
+ }
2415
+ }
2416
+ })
2417
+
2418
+ app.post('/cli/session/new', async (c) => {
2419
+ const body = (await c.req.json().catch(() => ({}))) as {
2420
+ extensionId?: string | null
2421
+ cwd?: string
2422
+ /** Direct CDP WebSocket URL — bypasses extension, connects straight to Chrome */
2423
+ cdpEndpoint?: string
2424
+ /** Launch a headless Chrome via chromium.launch() — no extension or relay CDP routing */
2425
+ headless?: boolean
2426
+ /** Browser name from discovery (e.g. "Chrome", "Brave") */
2427
+ browser?: string
2428
+ /** Profile info from discovery */
2429
+ profiles?: Array<{ name: string; email: string }>
2430
+ /** Cloud session tracking metadata (set by CLI when connecting to a cloud browser) */
2431
+ cloud?: {
2432
+ cloudSessionId: string
2433
+ cloudBaseUrl: string
2434
+ cloudToken: string
2435
+ /** BU VM hard timeout (ISO string or epoch ms) */
2436
+ timeoutAt?: string | number
2437
+ /** Block images/video/fonts to save proxy bandwidth */
2438
+ blockProxyResources?: boolean
2439
+ }
2440
+ }
2441
+ const sessionId = String(nextSessionNumber++)
2442
+ const cwd = body.cwd
2443
+
2444
+ // Headless mode: launch Chrome via chromium.launch(), no extension needed.
2445
+ // Force connection immediately so missing Chrome errors surface at creation time,
2446
+ // not on first execute call.
2447
+ if (body.headless) {
2448
+ const manager = await getExecutorManager()
2449
+ const executor = manager.getExecutor({
2450
+ sessionId,
2451
+ cwd,
2452
+ cdpConfig: { headless: true },
2453
+ sessionMetadata: {
2454
+ extensionId: null,
2455
+ browser: 'Chrome (Headless)',
2456
+ profile: null,
2457
+ },
2458
+ })
2459
+ try {
2460
+ await executor.reset()
2461
+ } catch (error) {
2462
+ manager.deleteExecutor(sessionId)
2463
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500)
2464
+ }
2465
+ const metadata = executor.getSessionMetadata()
2466
+ return c.json({
2467
+ id: sessionId,
2468
+ mode: 'headless' as const,
2469
+ extensionId: metadata.extensionId,
2470
+ browser: metadata.browser,
2471
+ profile: metadata.profile,
2472
+ })
2473
+ }
2474
+
2475
+ // Direct CDP mode: skip extension lookup, pass direct WebSocket URL to executor
2476
+ if (body.cdpEndpoint) {
2477
+ if (!body.cdpEndpoint.startsWith('ws://') && !body.cdpEndpoint.startsWith('wss://')) {
2478
+ return c.json({ error: `Invalid cdpEndpoint: must start with ws:// or wss:// (got: ${body.cdpEndpoint})` }, 400)
2479
+ }
2480
+ // Use first profile from discovery for session metadata (if available)
2481
+ const firstProfile = body.profiles?.[0]
2482
+ const cloudTimeoutAt = body.cloud?.timeoutAt
2483
+ ? (typeof body.cloud.timeoutAt === 'string' ? new Date(body.cloud.timeoutAt).getTime() : body.cloud.timeoutAt)
2484
+ : undefined
2485
+ const manager = await getExecutorManager()
2486
+ const executor = manager.getExecutor({
2487
+ sessionId,
2488
+ cwd,
2489
+ cdpConfig: { directCdpUrl: appendSessionToWsUrl(body.cdpEndpoint, sessionId) },
2490
+ sessionMetadata: {
2491
+ extensionId: null,
2492
+ browser: body.browser || null,
2493
+ profile: firstProfile ? { email: firstProfile.email, id: firstProfile.name } : null,
2494
+ },
2495
+ cloudSession: body.cloud ? { timeoutAt: cloudTimeoutAt, blockProxyResources: body.cloud.blockProxyResources } : undefined,
2496
+ })
2497
+ const metadata = executor.getSessionMetadata()
2498
+
2499
+ // Register cloud session tracking if cloud metadata was provided
2500
+ if (body.cloud) {
2501
+ cloudSessionTracking.set(sessionId, {
2502
+ cloudSessionId: body.cloud.cloudSessionId,
2503
+ cloudBaseUrl: body.cloud.cloudBaseUrl,
2504
+ cloudToken: body.cloud.cloudToken,
2505
+ lastActivityAt: Date.now(),
2506
+ activeExecutions: 0,
2507
+ timeoutAt: cloudTimeoutAt,
2508
+ })
2509
+ persistCloudSessions()
2510
+ }
2511
+
2512
+ return c.json({
2513
+ id: sessionId,
2514
+ mode: 'direct' as const,
2515
+ extensionId: metadata.extensionId,
2516
+ browser: metadata.browser,
2517
+ profile: metadata.profile,
2518
+ })
2519
+ }
2520
+
2521
+ // Extension mode (existing behavior)
2522
+ const extensionId = body.extensionId || null
2523
+ const allowDefault = !extensionId && store.getState().extensions.size === 1
2524
+ const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault })
2525
+ if (!conn) {
2526
+ const error = extensionId
2527
+ ? `Extension not connected: ${extensionId}`
2528
+ : 'Multiple extensions connected. Specify extensionId.'
2529
+ return c.json({ error }, 404)
2530
+ }
2531
+ const manager = await getExecutorManager()
2532
+ const executor = manager.getExecutor({
2533
+ sessionId,
2534
+ cwd,
2535
+ sessionMetadata: {
2536
+ extensionId: conn.stableKey,
2537
+ browser: conn.info.browser || null,
2538
+ profile: conn.info ? { email: conn.info.email || '', id: conn.info.id || '' } : null,
2539
+ },
2540
+ })
2541
+ const metadata = executor.getSessionMetadata()
2542
+ return c.json({
2543
+ id: sessionId,
2544
+ mode: 'extension' as const,
2545
+ extensionId: metadata.extensionId,
2546
+ browser: metadata.browser,
2547
+ profile: metadata.profile,
2548
+ })
2549
+ })
2550
+
2551
+ app.get('/cli/session/:id', async (c) => {
2552
+ const sessionId = c.req.param('id')
2553
+ const manager = await getExecutorManager()
2554
+ const executor = manager.getSession(sessionId)
2555
+ if (!executor) {
2556
+ return c.json({ error: 'not found' }, 404)
2557
+ }
2558
+ return c.json(executor.getSessionInfo({ id: sessionId }))
2559
+ })
2560
+
2561
+ app.post('/cli/session/delete', async (c) => {
2562
+ try {
2563
+ const body = (await c.req.json()) as { sessionId: string | number }
2564
+ const sessionId = normalizeSessionId(body.sessionId)
2565
+
2566
+ if (!sessionId) {
2567
+ return c.json({ error: 'sessionId is required' }, 400)
2568
+ }
2569
+
2570
+ const manager = await getExecutorManager()
2571
+ const executor = manager.getSession(sessionId)
2572
+
2573
+ // Close headless context before deleting to prevent context/page leaks
2574
+ // on the shared headless browser. Only affects headless sessions.
2575
+ if (executor) {
2576
+ await executor.closeHeadlessContext()
2577
+ }
2578
+
2579
+ const deleted = manager.deleteExecutor(sessionId)
2580
+
2581
+ if (!deleted) {
2582
+ return c.json({ error: `Session ${sessionId} not found` }, 404)
2583
+ }
2584
+
2585
+ // If this was a cloud-backed session, stop the VM only if no other
2586
+ // relay session is still using the same cloud VM (reference counting).
2587
+ const cloudTracking = cloudSessionTracking.get(sessionId)
2588
+ if (cloudTracking) {
2589
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, cloudTracking.cloudSessionId)
2590
+ cloudSessionTracking.delete(sessionId)
2591
+ persistCloudSessions()
2592
+ if (shouldStopVm) {
2593
+ disconnectCloudVm(cloudTracking)
2594
+ }
2595
+ }
2596
+
2597
+ return c.json({ success: true })
2598
+ } catch (error: any) {
2599
+ logger?.error('Delete session endpoint error:', error)
2600
+ return c.json({ error: error.message }, 500)
2601
+ }
2602
+ })
2603
+
2604
+ const legacyRecordingRemovedResponse = {
2605
+ success: false,
2606
+ error: 'Legacy video recording has been removed. Use /rrweb-recording/* and /rrweb-recordings instead.',
2607
+ }
2608
+
2609
+ app.post('/recording/start', (c) => {
2610
+ return c.json(legacyRecordingRemovedResponse, 410)
2611
+ })
2612
+
2613
+ app.post('/recording/stop', (c) => {
2614
+ return c.json(legacyRecordingRemovedResponse, 410)
2615
+ })
2616
+
2617
+ app.get('/recording/status', (c) => {
2618
+ return c.json({ isRecording: false })
2619
+ })
2620
+
2621
+ app.post('/recording/cancel', (c) => {
2622
+ return c.json(legacyRecordingRemovedResponse, 410)
2623
+ })
2624
+
2625
+ // ============================================================================
2626
+ // rrweb Recording Endpoints - For DOM replay recordings.
2627
+ // ============================================================================
2628
+
2629
+ app.post('/rrweb-recording/start', async (c) => {
2630
+ const body = (await c.req.json()) as {
2631
+ outputPath?: string
2632
+ sessionId?: string | number
2633
+ checkoutEveryNms?: number
2634
+ maskAllInputs?: boolean
2635
+ recordCanvas?: boolean
2636
+ inlineImages?: boolean
2637
+ collectFonts?: boolean
2638
+ mousemoveWait?: number
2639
+ }
2640
+ const sessionId = normalizeSessionId(body.sessionId)
2641
+ const { sessionId: _sessionId, ...recordingOptions } = body
2642
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
2643
+ const relay = getRrwebRecordingRelay(extensionId)
2644
+ if (!relay) {
2645
+ return c.json({ success: false, error: 'Extension not connected' }, 500)
2646
+ }
2647
+ const recordingParams = (resolvedSessionId
2648
+ ? { ...recordingOptions, sessionId: resolvedSessionId }
2649
+ : recordingOptions) as StartRrwebRecordingBody
2650
+ const result = await relay.startRecording(recordingParams)
2651
+ const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500
2652
+ return c.json(result, status)
2653
+ })
2654
+
2655
+ app.post('/rrweb-recording/stop', async (c) => {
2656
+ const body = (await c.req.json()) as { sessionId?: string | number }
2657
+ const sessionId = normalizeSessionId(body.sessionId)
2658
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
2659
+ const relay = getRrwebRecordingRelay(extensionId)
2660
+ if (!relay) {
2661
+ return c.json({ success: false, error: 'Extension not connected' }, 500)
2662
+ }
2663
+ const stopParams: StopRrwebRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
2664
+ const result = await relay.stopRecording(stopParams)
2665
+ const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500
2666
+ return c.json(result, status)
2667
+ })
2668
+
2669
+ app.get('/rrweb-recording/status', async (c) => {
2670
+ const sessionId = normalizeSessionId(c.req.query('sessionId'))
2671
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
2672
+ const relay = getRrwebRecordingRelay(extensionId)
2673
+ if (!relay) {
2674
+ return c.json({ isRecording: false })
2675
+ }
2676
+ const isRecordingParams: IsRrwebRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
2677
+ const result = await relay.isRecording(isRecordingParams)
2678
+ return c.json(result)
2679
+ })
2680
+
2681
+ app.post('/rrweb-recording/cancel', async (c) => {
2682
+ const body = (await c.req.json()) as { sessionId?: string | number }
2683
+ const sessionId = normalizeSessionId(body.sessionId)
2684
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
2685
+ const relay = getRrwebRecordingRelay(extensionId)
2686
+ if (!relay) {
2687
+ return c.json({ success: false, error: 'Extension not connected' }, 500)
2688
+ }
2689
+ const cancelParams: CancelRrwebRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
2690
+ const result = await relay.cancelRecording(cancelParams)
2691
+ return c.json(result)
2692
+ })
2693
+
2694
+ // ============================================================================
2695
+ // Cloud session idle tracking
2696
+ //
2697
+ // Tracks lastActivityAt for cloud-backed sessions (those created via
2698
+ // cdpEndpoint pointing to Browser Use VMs). A background interval checks
2699
+ // every 60s and disconnects sessions idle > 10 minutes by calling the
2700
+ // website's /api/cloud/disconnect endpoint.
2701
+ // ============================================================================
2702
+
2703
+ interface CloudSessionTracking {
2704
+ cloudSessionId: string
2705
+ /** Website base URL for disconnect calls */
2706
+ cloudBaseUrl: string
2707
+ /** Bearer token for website API */
2708
+ cloudToken: string
2709
+ lastActivityAt: number
2710
+ /** Number of currently running execute calls — skip idle timeout while > 0 */
2711
+ activeExecutions: number
2712
+ /** BU VM hard timeout (epoch ms) — used to warn users before expiration */
2713
+ timeoutAt?: number
2714
+ }
2715
+
2716
+ const cloudSessionTracking = new Map<string, CloudSessionTracking>()
2717
+ const CLOUD_IDLE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
2718
+
2719
+ /** Check if any OTHER relay session references the same cloud VM.
2720
+ * Used to prevent stopping a VM that's still used by another relay session
2721
+ * (e.g. user attached twice via `session new --browser cloud-1`). */
2722
+ function hasOtherCloudReferences(relaySessionId: string, cloudSessionId: string): boolean {
2723
+ for (const [otherId, tracking] of cloudSessionTracking) {
2724
+ if (otherId !== relaySessionId && tracking.cloudSessionId === cloudSessionId) {
2725
+ return true
2726
+ }
2727
+ }
2728
+ return false
2729
+ }
2730
+
2731
+ /** Disconnect a cloud VM via the website API (best-effort, non-blocking). */
2732
+ function disconnectCloudVm(tracking: CloudSessionTracking): void {
2733
+ fetch(new URL('/api/cloud/disconnect', tracking.cloudBaseUrl).toString(), {
2734
+ method: 'POST',
2735
+ headers: {
2736
+ Authorization: `Bearer ${tracking.cloudToken}`,
2737
+ 'Content-Type': 'application/json',
2738
+ },
2739
+ body: JSON.stringify({ cloudSessionId: tracking.cloudSessionId }),
2740
+ }).catch((err) => {
2741
+ logger?.error('[Cloud] Failed to disconnect cloud session:', err)
2742
+ })
2743
+ }
2744
+
2745
+ // ── Cloud session crash recovery ──────────────────────────────────
2746
+ // Persist cloud session IDs to disk so orphaned VMs can be cleaned up
2747
+ // if the relay process crashes. On startup, read the file and disconnect
2748
+ // any leftover VMs (best-effort).
2749
+
2750
+ const CLOUD_SESSIONS_FILE = path.join(getTabwrightUserDataDir(), 'cloud-sessions.json')
2751
+
2752
+ interface PersistedCloudSession {
2753
+ cloudSessionId: string
2754
+ cloudBaseUrl: string
2755
+ cloudToken: string
2756
+ }
2757
+
2758
+ function persistCloudSessions(): void {
2759
+ // Dedupe by cloudSessionId — multiple relay sessions can reference the same VM
2760
+ const seen = new Set<string>()
2761
+ const entries: PersistedCloudSession[] = []
2762
+ for (const t of cloudSessionTracking.values()) {
2763
+ if (seen.has(t.cloudSessionId)) continue
2764
+ seen.add(t.cloudSessionId)
2765
+ entries.push({
2766
+ cloudSessionId: t.cloudSessionId,
2767
+ cloudBaseUrl: t.cloudBaseUrl,
2768
+ cloudToken: t.cloudToken,
2769
+ })
2770
+ }
2771
+ try {
2772
+ const dir = path.dirname(CLOUD_SESSIONS_FILE)
2773
+ fs.mkdirSync(dir, { recursive: true })
2774
+ if (entries.length > 0) {
2775
+ // Atomic write: write to temp file then rename, so a crash mid-write
2776
+ // doesn't leave corrupt JSON that blocks future cleanup.
2777
+ const tmpFile = CLOUD_SESSIONS_FILE + '.tmp'
2778
+ fs.writeFileSync(tmpFile, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
2779
+ fs.renameSync(tmpFile, CLOUD_SESSIONS_FILE)
2780
+ } else {
2781
+ // No active sessions — remove file to avoid stale data
2782
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* already gone */ }
2783
+ }
2784
+ } catch {
2785
+ // Best-effort: don't crash relay if disk write fails
2786
+ }
2787
+ }
2788
+
2789
+ function cleanupOrphanedCloudSessions(): void {
2790
+ let raw: string
2791
+ try {
2792
+ raw = fs.readFileSync(CLOUD_SESSIONS_FILE, 'utf-8')
2793
+ } catch {
2794
+ return // No file — nothing to clean up
2795
+ }
2796
+
2797
+ let entries: PersistedCloudSession[]
2798
+ try {
2799
+ const parsed = JSON.parse(raw)
2800
+ if (!Array.isArray(parsed)) return
2801
+ // Validate shape: each entry must have cloudSessionId and cloudBaseUrl
2802
+ entries = parsed.filter((e): e is PersistedCloudSession => {
2803
+ return e && typeof e.cloudSessionId === 'string' && typeof e.cloudBaseUrl === 'string' && typeof e.cloudToken === 'string'
2804
+ })
2805
+ } catch {
2806
+ // Corrupt JSON (e.g. crash during non-atomic write) — just remove it
2807
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2808
+ return
2809
+ }
2810
+ if (!entries.length) {
2811
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2812
+ return
2813
+ }
2814
+
2815
+ logger?.log(pc.yellow(`[Cloud] Found ${entries.length} orphaned cloud session(s) from previous relay. Cleaning up...`))
2816
+ // Remove file after we've read it — disconnect calls are best-effort async.
2817
+ // If they fail, the BU VM will eventually hit its own timeout anyway.
2818
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2819
+
2820
+ for (const entry of entries) {
2821
+ disconnectCloudVm({
2822
+ cloudSessionId: entry.cloudSessionId,
2823
+ cloudBaseUrl: entry.cloudBaseUrl,
2824
+ cloudToken: entry.cloudToken,
2825
+ lastActivityAt: 0,
2826
+ activeExecutions: 0,
2827
+ })
2828
+ }
2829
+ }
2830
+
2831
+ const cloudIdleInterval = setInterval(async () => {
2832
+ const now = Date.now()
2833
+ // Collect idle sessions first, then process — avoid mutating map during iteration
2834
+ const idleSessions: Array<[string, CloudSessionTracking]> = []
2835
+ for (const [sessionId, tracking] of cloudSessionTracking) {
2836
+ // VM already past BU hard timeout — schedule for cleanup regardless of activity
2837
+ if (tracking.timeoutAt && tracking.timeoutAt <= now) {
2838
+ idleSessions.push([sessionId, tracking])
2839
+ continue
2840
+ }
2841
+ // Timeout warnings are handled by the executor on each execute() call
2842
+ // (deduped by minute bucket) — no need to enqueue from the relay interval.
2843
+
2844
+ if (tracking.activeExecutions > 0) continue
2845
+ if (now - tracking.lastActivityAt > CLOUD_IDLE_TIMEOUT_MS) {
2846
+ idleSessions.push([sessionId, tracking])
2847
+ }
2848
+ }
2849
+
2850
+ if (idleSessions.length > 0) {
2851
+ for (const [sessionId, tracking] of idleSessions) {
2852
+ logger?.log(
2853
+ pc.yellow(`[Cloud] Stopping idle relay session ${sessionId} (idle > 10 min)`),
2854
+ )
2855
+ // Check if other relay sessions reference the same cloud VM.
2856
+ // Only stop the VM when this is the last relay session for it.
2857
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, tracking.cloudSessionId)
2858
+ cloudSessionTracking.delete(sessionId)
2859
+ executorManager?.deleteExecutor(sessionId)
2860
+ if (shouldStopVm) {
2861
+ disconnectCloudVm(tracking)
2862
+ }
2863
+ }
2864
+ persistCloudSessions()
2865
+ }
2866
+ }, 60_000)
2867
+
2868
+ // Use createAdaptorServer instead of serve() so we control the listen()
2869
+ // timing. This lets us inject WebSocket upgrade handlers before binding and
2870
+ // await the bind to surface EADDRINUSE as a catchable error (issue #75).
2871
+ const server = createAdaptorServer({ fetch: app.fetch, hostname: host })
2872
+ injectWebSocket(server)
2873
+
2874
+ await new Promise<void>((resolve, reject) => {
2875
+ const onListening = () => {
2876
+ server.off('error', onError)
2877
+ resolve()
2878
+ }
2879
+ const onError = (error: Error) => {
2880
+ server.off('listening', onListening)
2881
+ reject(error)
2882
+ }
2883
+ server.once('listening', onListening)
2884
+ server.once('error', onError)
2885
+ server.listen(port, host)
2886
+ })
2887
+
2888
+ // Clean up orphaned cloud sessions from a previous relay crash.
2889
+ // Must run AFTER successful listen — if another relay is already running,
2890
+ // we'd fail with EADDRINUSE but only after killing its live VMs.
2891
+ cleanupOrphanedCloudSessions()
2892
+
2893
+ const wsHost = `ws://${host}:${port}`
2894
+ const cdpEndpoint = `${wsHost}/cdp`
2895
+ const extensionEndpoint = `${wsHost}/extension`
2896
+
2897
+ logger?.log('CDP relay server started')
2898
+ logger?.log('Host:', host)
2899
+ logger?.log('Port:', port)
2900
+ logger?.log('Extension endpoint:', extensionEndpoint)
2901
+ logger?.log('CDP endpoint:', cdpEndpoint)
2902
+
2903
+ return {
2904
+ close() {
2905
+ const { extensions, playwrightClients } = store.getState()
2906
+
2907
+ for (const client of playwrightClients.values()) {
2908
+ client.ws.close(1000, 'Server stopped')
2909
+ }
2910
+
2911
+ for (const ext of extensions.values()) {
2912
+ if (ext.pingInterval) {
2913
+ clearInterval(ext.pingInterval)
2914
+ }
2915
+ ext.ws?.close(1000, 'Server stopped')
2916
+ }
2917
+
2918
+ // Close shared headless browser if any headless sessions were created (fire-and-forget)
2919
+ void import('./executor.js').then(({ PlaywrightExecutor }) => {
2920
+ return PlaywrightExecutor.closeSharedHeadlessBrowser()
2921
+ })
2922
+
2923
+ // Reset store state
2924
+ store.setState({
2925
+ extensions: new Map(),
2926
+ playwrightClients: new Map(),
2927
+ })
2928
+ clearInterval(cloudIdleInterval)
2929
+ cloudSessionTracking.clear()
2930
+ persistCloudSessions() // Remove the file on graceful shutdown
2931
+ server.close()
2932
+ emitter.removeAllListeners()
2933
+ },
2934
+ on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
2935
+ emitter.on(event, listener as (...args: unknown[]) => void)
2936
+ },
2937
+ off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
2938
+ emitter.off(event, listener as (...args: unknown[]) => void)
2939
+ },
2940
+ }
2941
+ }
2942
+
2943
+ // Kept so extensions and library consumers can upgrade independently during the rename.
2944
+ export const startPlayWriterCDPRelayServer = startTabwrightCDPRelayServer