@hraness/ghostget 0.17.2

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 (500) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1359 -0
  4. package/bunfig.toml +2 -0
  5. package/dist/apple-photos-client.js +704 -0
  6. package/dist/beeper-client.js +4602 -0
  7. package/dist/client.js +1605 -0
  8. package/dist/index-26yq8q16.js +31 -0
  9. package/dist/index-9t13b4k7.js +508 -0
  10. package/dist/index-axwm9b3v.js +5 -0
  11. package/dist/index-gwk7rbyj.js +30 -0
  12. package/dist/index.js +701 -0
  13. package/dist/messaging.js +1130 -0
  14. package/dist/omni-client.js +1406 -0
  15. package/dist/whatsapp-client.js +355 -0
  16. package/docs/imessage-direct-provider.md +106 -0
  17. package/docs/rental-listings.md +94 -0
  18. package/package.json +600 -0
  19. package/skills/ghostget/SKILL.md +251 -0
  20. package/skills/ghostget/agents/openai.yaml +4 -0
  21. package/skills/ghostget/references/adapter-contract.md +181 -0
  22. package/skills/ghostget/references/apple-photos.md +115 -0
  23. package/skills/ghostget/references/article-drafts.md +235 -0
  24. package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
  25. package/skills/ghostget/references/cross-posting-video.md +139 -0
  26. package/skills/ghostget/references/cross-posting.md +82 -0
  27. package/skills/ghostget/references/derivation.md +390 -0
  28. package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
  29. package/skills/ghostget/references/install.md +66 -0
  30. package/skills/ghostget/references/linkedin-adapter.md +442 -0
  31. package/skills/ghostget/references/messaging.md +168 -0
  32. package/skills/ghostget/references/meta-comet-contract.md +153 -0
  33. package/skills/ghostget/references/platform-patterns.md +122 -0
  34. package/skills/ghostget/references/provider-plugins.md +496 -0
  35. package/skills/ghostget/references/reddit-flair.md +78 -0
  36. package/skills/ghostget/references/safety-and-state.md +214 -0
  37. package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
  38. package/skills/ghostget/references/social-platform-routing.md +66 -0
  39. package/skills/ghostget/references/social-profile-stats.md +195 -0
  40. package/skills/ghostget/references/social-video-platform-routing.md +89 -0
  41. package/skills/ghostget/references/whatsapp-adapter.md +101 -0
  42. package/skills/ghostget/references/x-adapter.md +332 -0
  43. package/skills/ghostget/references/x-ai-disclosure.md +71 -0
  44. package/src/apple-photos-cli.ts +639 -0
  45. package/src/apple-photos-client-types.ts +168 -0
  46. package/src/apple-photos-client.ts +201 -0
  47. package/src/apple-photos-contact-evidence.ts +838 -0
  48. package/src/apple-photos-local-source.ts +1131 -0
  49. package/src/args.ts +2053 -0
  50. package/src/article-draft-document.ts +417 -0
  51. package/src/article-draft-embeds.ts +123 -0
  52. package/src/article-draft-images.ts +214 -0
  53. package/src/assets/adapter-template/wrench-adapter.json +40 -0
  54. package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
  55. package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
  56. package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
  57. package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
  58. package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
  59. package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
  60. package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
  61. package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
  62. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
  63. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
  64. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
  65. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
  66. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
  67. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
  68. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
  69. package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
  70. package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
  71. package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
  72. package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
  73. package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
  74. package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
  75. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
  76. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
  77. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
  78. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
  79. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
  80. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
  81. package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
  82. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
  83. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
  84. package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
  85. package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
  86. package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
  87. package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
  88. package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
  89. package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
  90. package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
  91. package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
  92. package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
  93. package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
  94. package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
  95. package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
  96. package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
  97. package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
  98. package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
  99. package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
  100. package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
  101. package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
  102. package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
  103. package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
  104. package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
  105. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
  106. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
  107. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
  108. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
  109. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
  110. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
  111. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
  112. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
  113. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
  114. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
  115. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
  116. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
  117. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
  118. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
  119. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
  120. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
  121. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
  122. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
  123. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
  124. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
  125. package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
  126. package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
  127. package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
  128. package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
  129. package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
  130. package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
  131. package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
  132. package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
  133. package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
  134. package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
  135. package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
  136. package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
  137. package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
  138. package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
  139. package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
  140. package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
  141. package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
  142. package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
  143. package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
  144. package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
  145. package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
  146. package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
  147. package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
  148. package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
  149. package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
  150. package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
  151. package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
  152. package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
  153. package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
  154. package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
  155. package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
  156. package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
  157. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
  158. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
  159. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
  160. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
  161. package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
  162. package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
  163. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
  164. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
  165. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
  166. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
  167. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
  168. package/src/assets/adapters/x/wrench-adapter.json +749 -0
  169. package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
  170. package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
  171. package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
  172. package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
  173. package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
  174. package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
  175. package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
  176. package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
  177. package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
  178. package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
  179. package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
  180. package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
  181. package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
  182. package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
  183. package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
  184. package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
  185. package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
  186. package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
  187. package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
  188. package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
  189. package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
  190. package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
  191. package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
  192. package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
  193. package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
  194. package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
  195. package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
  196. package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  197. package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
  198. package/src/assets.d.ts +4 -0
  199. package/src/auth.ts +1157 -0
  200. package/src/beeper-client-types.ts +169 -0
  201. package/src/beeper-client.ts +250 -0
  202. package/src/beeper-contact-interactions-cli.ts +180 -0
  203. package/src/beeper-contact-interactions.ts +1612 -0
  204. package/src/beeper-message-bundle-v1.ts +257 -0
  205. package/src/beeper-message-like-me-cli.ts +84 -0
  206. package/src/beeper-message-like-me-export.ts +2274 -0
  207. package/src/beeper-message-like-me-recovery.ts +931 -0
  208. package/src/beeper-message-like-me-source.ts +4034 -0
  209. package/src/browser-admission.ts +928 -0
  210. package/src/browser-snapshots.ts +260 -0
  211. package/src/browser.ts +3873 -0
  212. package/src/canonical-json.ts +60 -0
  213. package/src/catalog-cli.ts +638 -0
  214. package/src/cli.ts +208 -0
  215. package/src/client-types.ts +301 -0
  216. package/src/client.ts +2726 -0
  217. package/src/confirmed-write-failure.ts +44 -0
  218. package/src/confirmed-write-model.ts +72 -0
  219. package/src/confirmed-write-platform.ts +1055 -0
  220. package/src/confirmed-write-program.ts +224 -0
  221. package/src/confirmed-write-runtime.ts +16 -0
  222. package/src/cursor-token.ts +228 -0
  223. package/src/derivation-dnr-readiness.ts +367 -0
  224. package/src/derivation-file-chooser.ts +384 -0
  225. package/src/derivation-network-boundary.ts +835 -0
  226. package/src/derivation-network-guard.ts +782 -0
  227. package/src/derivation-network-proxy-helper.ts +440 -0
  228. package/src/derivation-network-proxy.ts +385 -0
  229. package/src/derive-command-helper.ts +693 -0
  230. package/src/derive-fixtures.ts +343 -0
  231. package/src/derive-review.ts +980 -0
  232. package/src/derive.ts +4754 -0
  233. package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
  234. package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
  235. package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
  236. package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
  237. package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
  238. package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
  239. package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
  240. package/src/ghostget.ts +3672 -0
  241. package/src/gmail-capture.ts +1244 -0
  242. package/src/har-internal.ts +1242 -0
  243. package/src/har.ts +743 -0
  244. package/src/index.ts +218 -0
  245. package/src/invocation-read-platform.ts +55 -0
  246. package/src/invocation-read-program.ts +93 -0
  247. package/src/invocation-read-runtime.ts +11 -0
  248. package/src/linked-device-lifecycle-admission.ts +450 -0
  249. package/src/linked-device-lifecycle-journal.ts +1560 -0
  250. package/src/linked-device-lifecycle-runtime.ts +1602 -0
  251. package/src/local-cli-admission.ts +135 -0
  252. package/src/local-cli-contracts.ts +271 -0
  253. package/src/local-cli-execution.ts +98 -0
  254. package/src/local-cli-surface-contract.ts +1510 -0
  255. package/src/local-cli-tool-identity.ts +336 -0
  256. package/src/media/archive.ts +2325 -0
  257. package/src/media/args.ts +303 -0
  258. package/src/media/cli.ts +287 -0
  259. package/src/media/doctor.ts +475 -0
  260. package/src/media/ffmpeg.ts +1330 -0
  261. package/src/media/http-capture.ts +711 -0
  262. package/src/media/http-probe.ts +593 -0
  263. package/src/media/http.ts +387 -0
  264. package/src/media/index.ts +10 -0
  265. package/src/media/local-transcription.ts +613 -0
  266. package/src/media/lock.ts +321 -0
  267. package/src/media/manifest.ts +1712 -0
  268. package/src/media/metadata.ts +906 -0
  269. package/src/media/process.ts +723 -0
  270. package/src/media/revision.ts +166 -0
  271. package/src/media/runtime-closure.ts +1063 -0
  272. package/src/media/source-router.ts +187 -0
  273. package/src/media/transcriber-config.ts +1122 -0
  274. package/src/media/transcript-persistence-model.ts +15 -0
  275. package/src/media/transcript-persistence-platform.ts +89 -0
  276. package/src/media/transcript-persistence-program.ts +39 -0
  277. package/src/media/transcript-persistence-runtime.ts +24 -0
  278. package/src/media/transcript.ts +616 -0
  279. package/src/media/utf8-order.ts +30 -0
  280. package/src/media/whisper-cpp.ts +464 -0
  281. package/src/media/whisper-language.ts +58 -0
  282. package/src/media/yt-dlp.ts +439 -0
  283. package/src/message-like-me-agentic-messaging.ts +882 -0
  284. package/src/messaging-action-store.ts +793 -0
  285. package/src/messaging-runtime.ts +2136 -0
  286. package/src/messaging-store.ts +539 -0
  287. package/src/messaging-types.ts +1663 -0
  288. package/src/messaging.ts +334 -0
  289. package/src/model.ts +2671 -0
  290. package/src/oauth-google.ts +888 -0
  291. package/src/omni-client-types.ts +306 -0
  292. package/src/omni-client.ts +2225 -0
  293. package/src/omni-limits.ts +34 -0
  294. package/src/omni-model.ts +1614 -0
  295. package/src/omni-request.ts +379 -0
  296. package/src/omni-runtime.ts +1632 -0
  297. package/src/operation-deadline.ts +189 -0
  298. package/src/path-helper.ts +1656 -0
  299. package/src/pinned-https.ts +194 -0
  300. package/src/plan-assets.ts +832 -0
  301. package/src/platform-catalog.ts +1832 -0
  302. package/src/plugins/beeper-linked-device/plugin.ts +310 -0
  303. package/src/plugins/bluesky-web/plugin.ts +178 -0
  304. package/src/plugins/clasificados-web/plugin.ts +66 -0
  305. package/src/plugins/github-web/plugin.ts +80 -0
  306. package/src/plugins/gmail-official/plugin.ts +110 -0
  307. package/src/plugins/hacker-news-web/plugin.ts +52 -0
  308. package/src/plugins/imessage-direct/plugin.ts +195 -0
  309. package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
  310. package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
  311. package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
  312. package/src/plugins/linkedin-official/plugin.ts +84 -0
  313. package/src/plugins/linkedin-web/plugin.ts +412 -0
  314. package/src/plugins/meta-web/plugin.ts +317 -0
  315. package/src/plugins/reddit-web/flair.ts +492 -0
  316. package/src/plugins/reddit-web/plugin.ts +477 -0
  317. package/src/plugins/substack-web/plugin.ts +124 -0
  318. package/src/plugins/tiktok-web/plugin.ts +67 -0
  319. package/src/plugins/twitch-web/plugin.ts +54 -0
  320. package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
  321. package/src/plugins/x-official/plugin.ts +64 -0
  322. package/src/plugins/x-web/plugin.ts +488 -0
  323. package/src/plugins/youtube-web/plugin.ts +101 -0
  324. package/src/portable-run-recovery.ts +466 -0
  325. package/src/process-identity.ts +501 -0
  326. package/src/profile-clone-helper.ts +104 -0
  327. package/src/provider-catalog-views.ts +51 -0
  328. package/src/provider-context.ts +69 -0
  329. package/src/provider-contract-definitions-x.ts +45 -0
  330. package/src/provider-contract-definitions.ts +948 -0
  331. package/src/provider-contract-input-gmail.ts +176 -0
  332. package/src/provider-contract-input-linkedin.ts +77 -0
  333. package/src/provider-contract-input-x.ts +304 -0
  334. package/src/provider-contract-planning.ts +23 -0
  335. package/src/provider-contract-semantic-identity.ts +54 -0
  336. package/src/provider-contracts.ts +251 -0
  337. package/src/provider-http.ts +723 -0
  338. package/src/provider-plugin-auth.ts +36 -0
  339. package/src/provider-plugin-builtins.ts +229 -0
  340. package/src/provider-plugin-cleanup-barrier.ts +229 -0
  341. package/src/provider-plugin-cleanup-execution.ts +79 -0
  342. package/src/provider-plugin-cleanup-resource.ts +470 -0
  343. package/src/provider-plugin-contract-identity.ts +707 -0
  344. package/src/provider-plugin-host.ts +1972 -0
  345. package/src/provider-plugin-identifiers.ts +60 -0
  346. package/src/provider-plugin-invocation-lease.ts +889 -0
  347. package/src/provider-plugin-lifecycle-kernel.ts +566 -0
  348. package/src/provider-plugin-lifecycle.ts +1455 -0
  349. package/src/provider-plugin-module-analysis.ts +336 -0
  350. package/src/provider-plugin-package.ts +2530 -0
  351. package/src/provider-plugin-portable-authority.ts +106 -0
  352. package/src/provider-plugin-portable-catalog.ts +94 -0
  353. package/src/provider-plugin-portable-identity.ts +315 -0
  354. package/src/provider-plugin-portable-runtime.ts +2057 -0
  355. package/src/provider-plugin-protocol.ts +1625 -0
  356. package/src/provider-plugin-registry.ts +3342 -0
  357. package/src/provider-plugin-store.ts +2155 -0
  358. package/src/provider-plugin-test-fixture.ts +5 -0
  359. package/src/provider-plugin.ts +4907 -0
  360. package/src/provider-plugins.generated.ts +43 -0
  361. package/src/provider-plugins.ts +8 -0
  362. package/src/provider-subject.ts +41 -0
  363. package/src/provider.ts +315 -0
  364. package/src/providers/beeper-local-runtime.ts +5426 -0
  365. package/src/providers/beeper-local.ts +2739 -0
  366. package/src/providers/beeper-messaging.ts +462 -0
  367. package/src/providers/beeper-omni.ts +1159 -0
  368. package/src/providers/bluesky-web-runtime.ts +2895 -0
  369. package/src/providers/bluesky-web.ts +1614 -0
  370. package/src/providers/clasificados-web-runtime.ts +255 -0
  371. package/src/providers/clasificados-web.ts +503 -0
  372. package/src/providers/contact-projection.ts +301 -0
  373. package/src/providers/github-read-model.ts +94 -0
  374. package/src/providers/github-read-platform.ts +165 -0
  375. package/src/providers/github-read-program.ts +97 -0
  376. package/src/providers/github-web-runtime.ts +320 -0
  377. package/src/providers/github-web.ts +403 -0
  378. package/src/providers/gmail-api.ts +2834 -0
  379. package/src/providers/gmail-omni.ts +830 -0
  380. package/src/providers/gmail.ts +1130 -0
  381. package/src/providers/hacker-news-web-runtime.ts +220 -0
  382. package/src/providers/hacker-news-web.ts +809 -0
  383. package/src/providers/imessage-direct-install.ts +354 -0
  384. package/src/providers/imessage-direct-messaging.ts +532 -0
  385. package/src/providers/imessage-direct-omni.ts +400 -0
  386. package/src/providers/imessage-direct-runtime.ts +1498 -0
  387. package/src/providers/imessage-direct.ts +351 -0
  388. package/src/providers/instagram-video-foundations.ts +913 -0
  389. package/src/providers/instagram-web-profile-browser.ts +600 -0
  390. package/src/providers/iso-bmff.ts +338 -0
  391. package/src/providers/linkedin-company-platform.ts +42 -0
  392. package/src/providers/linkedin-company-program.ts +88 -0
  393. package/src/providers/linkedin-contact-failure.ts +84 -0
  394. package/src/providers/linkedin-contact-platform.ts +45 -0
  395. package/src/providers/linkedin-contact-program.ts +91 -0
  396. package/src/providers/linkedin-profile-activity-failure.ts +76 -0
  397. package/src/providers/linkedin-profile-activity-platform.ts +41 -0
  398. package/src/providers/linkedin-profile-activity-program.ts +67 -0
  399. package/src/providers/linkedin-read-failure.ts +52 -0
  400. package/src/providers/linkedin-self-platform.ts +53 -0
  401. package/src/providers/linkedin-self-program.ts +122 -0
  402. package/src/providers/linkedin-web-article-browser.ts +794 -0
  403. package/src/providers/linkedin-web-bootstrap.ts +146 -0
  404. package/src/providers/linkedin-web-contact.ts +830 -0
  405. package/src/providers/linkedin-web-feed-browser.ts +832 -0
  406. package/src/providers/linkedin-web-feed.ts +864 -0
  407. package/src/providers/linkedin-web-post-browser.ts +653 -0
  408. package/src/providers/linkedin-web-profile-browser.ts +683 -0
  409. package/src/providers/linkedin-web-runtime.ts +2388 -0
  410. package/src/providers/linkedin-web.ts +3305 -0
  411. package/src/providers/linkedin.ts +1885 -0
  412. package/src/providers/meta-bootstrap.ts +1043 -0
  413. package/src/providers/meta-facebook-group.ts +690 -0
  414. package/src/providers/meta-marketplace-relay.ts +747 -0
  415. package/src/providers/meta-omni.ts +373 -0
  416. package/src/providers/meta-relay-bundle.ts +1049 -0
  417. package/src/providers/meta-web-descriptors.ts +1740 -0
  418. package/src/providers/meta-web-runtime.ts +3388 -0
  419. package/src/providers/meta-web.ts +2931 -0
  420. package/src/providers/read-failure.ts +139 -0
  421. package/src/providers/reddit-omni.ts +462 -0
  422. package/src/providers/reddit-web-runtime.ts +1929 -0
  423. package/src/providers/reddit-web.ts +1611 -0
  424. package/src/providers/rental-listings.ts +460 -0
  425. package/src/providers/substack-omni.ts +406 -0
  426. package/src/providers/substack-video-mp4.ts +33 -0
  427. package/src/providers/substack-web-runtime.ts +2812 -0
  428. package/src/providers/substack-web.ts +1502 -0
  429. package/src/providers/tiktok-video-mp4.ts +33 -0
  430. package/src/providers/tiktok-web-runtime.ts +770 -0
  431. package/src/providers/tiktok-web.ts +2786 -0
  432. package/src/providers/twitch-web-runtime.ts +226 -0
  433. package/src/providers/twitch-web.ts +274 -0
  434. package/src/providers/whatsapp-account-identity.ts +55 -0
  435. package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
  436. package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
  437. package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
  438. package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
  439. package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
  440. package/src/providers/whatsapp-messaging.ts +132 -0
  441. package/src/providers/whatsapp-omni.ts +586 -0
  442. package/src/providers/whatsapp-web-runtime.ts +3108 -0
  443. package/src/providers/whatsapp-web.ts +1037 -0
  444. package/src/providers/x-image-provenance.ts +523 -0
  445. package/src/providers/x-made-with-ai.ts +106 -0
  446. package/src/providers/x-transaction-id.ts +413 -0
  447. package/src/providers/x-web-runtime.ts +3485 -0
  448. package/src/providers/x-web.ts +2332 -0
  449. package/src/providers/x.ts +1889 -0
  450. package/src/providers/youtube-web-runtime.ts +1400 -0
  451. package/src/providers/youtube-web.ts +1221 -0
  452. package/src/read-admission-runtime.ts +91 -0
  453. package/src/read-client.ts +521 -0
  454. package/src/read-effect-platform.ts +7 -0
  455. package/src/read-effect-runtime.ts +14 -0
  456. package/src/read-effect.ts +52 -0
  457. package/src/read-projection-admission.ts +866 -0
  458. package/src/read-projections.ts +3151 -0
  459. package/src/recovery.ts +1492 -0
  460. package/src/reviewed-template.ts +146 -0
  461. package/src/run-journal.ts +1272 -0
  462. package/src/runtime.ts +5116 -0
  463. package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
  464. package/src/scripts/install-whatsapp-protocol.sh +232 -0
  465. package/src/scripts/recover-install-stages.ts +1491 -0
  466. package/src/scripts/resolve-state-home.ts +30 -0
  467. package/src/scripts/scaffold-web-provider.ts +973 -0
  468. package/src/scripts/sync-bundled-adapters.ts +695 -0
  469. package/src/session-secrets.ts +1183 -0
  470. package/src/state-helper.bunfig.toml +2 -0
  471. package/src/state-helper.ts +2914 -0
  472. package/src/storage.ts +3842 -0
  473. package/src/transport-policy.ts +2 -0
  474. package/src/usage.ts +182 -0
  475. package/src/version.ts +2 -0
  476. package/src/web-session-authentication-policy.ts +136 -0
  477. package/src/web-session-cleanup-admission.ts +2344 -0
  478. package/src/web-session-cleanup-join.ts +99 -0
  479. package/src/web-session-client.ts +996 -0
  480. package/src/web-session-contract-definitions.ts +820 -0
  481. package/src/web-session-contract-planning.ts +42 -0
  482. package/src/web-session-contracts.ts +228 -0
  483. package/src/web-session-cookies.ts +500 -0
  484. package/src/web-session-execution.ts +385 -0
  485. package/src/web-session-http.ts +373 -0
  486. package/src/web-session-read-errors.ts +19 -0
  487. package/src/web-session-read-runtime.ts +124 -0
  488. package/src/web-session-recovery.ts +818 -0
  489. package/src/web-session-sites.ts +58 -0
  490. package/src/web-session-template.ts +994 -0
  491. package/src/web-session.ts +93 -0
  492. package/src/whatsapp-client-binding.ts +21 -0
  493. package/src/whatsapp-client-types.ts +75 -0
  494. package/src/whatsapp-client.ts +395 -0
  495. package/src/whatsapp-export-coordinate.ts +17 -0
  496. package/src/whatsapp-message-bundle-v2.ts +195 -0
  497. package/src/whatsapp-message-like-me-cli.ts +195 -0
  498. package/src/whatsapp-message-like-me-export.ts +1072 -0
  499. package/src/whatsapp-message-like-me-source.ts +1012 -0
  500. package/tsconfig.json +67 -0
package/src/browser.ts ADDED
@@ -0,0 +1,3873 @@
1
+ import {
2
+ chmodSync,
3
+ lstatSync,
4
+ mkdtempSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createConnection } from "node:net";
10
+ import { tmpdir } from "node:os";
11
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
12
+ import { types as nodeTypes } from "node:util";
13
+
14
+ import {
15
+ acquireCookieRecords,
16
+ agentBrowserCommand as packageAgentBrowserCommand,
17
+ browserCookieCommands,
18
+ browserProxyArguments,
19
+ type CookieRecordReader,
20
+ } from "@hraness/kb/clip/acquire";
21
+ import { BoundedByteBuffer } from "@hraness/kb/clip/bounded-byte-buffer";
22
+ import {
23
+ cloneBrowserProfile,
24
+ cloneProfile,
25
+ copyBoundedLocalState,
26
+ isSafeNamedProfile,
27
+ profilePath,
28
+ type ClonedBrowserProfile,
29
+ } from "@hraness/kb/browser-profiles";
30
+ import { startNetworkProxy, type LocalNetworkProxy } from "@hraness/kb/clip/network-proxy";
31
+ import { redactSensitiveText } from "@hraness/kb/clip/persist";
32
+ import { sanitizeTerminalLine } from "@hraness/kb/clip/terminal";
33
+ import type { GhostgetAuth } from "./auth";
34
+ import type { OperationDeadline } from "./operation-deadline";
35
+ import type {
36
+ BrowserRecipe,
37
+ FileInputValue,
38
+ OperationInput,
39
+ GhostgetManifest,
40
+ } from "./model";
41
+ import { localBrowserCdpUrl } from "./derivation-file-chooser";
42
+ import { DOM_ACTION_TRANSPORT_DISABLED_MESSAGE } from "./transport-policy";
43
+ import {
44
+ captureProcessOwnerIdentity,
45
+ processOwnerStatus,
46
+ type ProcessOwnerIdentity,
47
+ type ProcessOwnerStatus,
48
+ } from "./process-identity";
49
+
50
+ type JsonRecord = Record<string, unknown>;
51
+
52
+ /** Browser recipes are never an action transport for registered signed-in sites. */
53
+ export function assertBrowserManifestOriginPolicy(
54
+ manifest: GhostgetManifest,
55
+ isProtectedHostname: (hostname: string) => boolean,
56
+ ): void {
57
+ for (const domain of manifest.browserDomains) {
58
+ if (isProtectedHostname(domain)) {
59
+ throw new Error(
60
+ `scripted browser actions are prohibited on protected signed-in site domain ${domain}; use a code-owned schemaVersion 3 official provider or schemaVersion 4 authenticated internal API`,
61
+ );
62
+ }
63
+ }
64
+ for (const origin of manifest.origins) {
65
+ let hostname: string;
66
+ try {
67
+ hostname = new URL(origin).hostname;
68
+ } catch {
69
+ throw new Error("browser action manifest contains an invalid origin");
70
+ }
71
+ if (isProtectedHostname(hostname)) {
72
+ throw new Error(
73
+ `scripted browser actions are prohibited on protected signed-in site hostname ${hostname}; use a code-owned schemaVersion 3 official provider or schemaVersion 4 authenticated internal API`,
74
+ );
75
+ }
76
+ }
77
+ }
78
+
79
+ export type BrowserExecution = {
80
+ readonly status: "succeeded" | "failed" | "partial" | "indeterminate";
81
+ readonly output: unknown;
82
+ readonly finalUrl: string | null;
83
+ readonly dispatchStarted: boolean;
84
+ readonly dispatch: {
85
+ readonly planned: number;
86
+ readonly started: number;
87
+ readonly verified: number;
88
+ };
89
+ readonly error?: string;
90
+ readonly privateArtifactsPreserved?: boolean;
91
+ readonly recoveryHandle?: string;
92
+ };
93
+
94
+ export type BrowserDispatchEvent = {
95
+ readonly id: string;
96
+ readonly index: number;
97
+ readonly progress: BrowserExecution["dispatch"];
98
+ };
99
+
100
+ export type BrowserFileResolver = (
101
+ files: readonly FileInputValue[],
102
+ ) => Promise<readonly string[]>;
103
+
104
+ export type CommandResult = {
105
+ readonly stdout: string;
106
+ readonly stderr: string;
107
+ readonly exitCode: number;
108
+ };
109
+
110
+ export type BrowserSession = {
111
+ readonly runBatch: (commands: readonly (readonly string[])[], timeoutMs: number, maxOutputBytes: number) => Promise<readonly JsonRecord[]>;
112
+ /** Waits for every in-flight batch to settle before closing the browser. */
113
+ readonly close: () => Promise<void>;
114
+ /** Refuses to touch private resources until close is acknowledged or independently proved. */
115
+ readonly cleanup: () => Promise<void>;
116
+ readonly recoveryHandle?: string;
117
+ readonly cleanupResourceIdentity?: BrowserCleanupResourceIdentity;
118
+ };
119
+
120
+ type BrowserPrivateArtifactRemover = (path: string) => void;
121
+
122
+ export type BrowserSessionDependencies = {
123
+ /** Abort-aware runner that settles only after its child process tree is reaped. */
124
+ readonly runCommand: typeof runCommand;
125
+ readonly startNetworkProxy: typeof startNetworkProxy;
126
+ readonly acquireCookieRecords: CookieRecordReader;
127
+ /** Internal deterministic seam for independently removing owned private roots. */
128
+ readonly removePrivateArtifact: BrowserPrivateArtifactRemover;
129
+ /** Internal seam for deterministic cleanup quiescence and deletion reproof. */
130
+ readonly cleanupLifecycle: AgentBrowserLifecycleDependencies;
131
+ };
132
+
133
+ /** Provider-neutral borrowed operation budget used by browser bootstraps. */
134
+ export type BrowserOperationDeadline = Pick<
135
+ OperationDeadline,
136
+ "signal" | "remainingTimeMs" | "run" | "throwIfUnavailable"
137
+ >;
138
+
139
+ export type CreateBrowserSessionOptions = {
140
+ readonly headed: boolean;
141
+ readonly timeoutMs: number;
142
+ readonly maxOutputBytes: number;
143
+ /** Kernel-owned total budget. Browser setup borrows and never disposes it. */
144
+ readonly operationDeadline?: BrowserOperationDeadline;
145
+ /**
146
+ * Internal code-owned resolvers may opt into the pinned evaluate command.
147
+ * Manifest recipes and derivation must never populate this option.
148
+ */
149
+ readonly allowCodeOwnedEvaluation?: true;
150
+ /**
151
+ * Internal code-owned bootstraps may inspect bounded request metadata while
152
+ * discovering a current first-party operation revision. Raw observations
153
+ * must be projected before they leave the provider module.
154
+ */
155
+ readonly allowCodeOwnedNetworkObservation?: true;
156
+ /**
157
+ * Persists the exact private roots before a proxy or browser can launch.
158
+ * The durable registrar reconciles an ambiguous commit before returning.
159
+ * Any other throw is still treated as potentially committed, so Ghostget
160
+ * preserves the roots instead of destroying recovery evidence.
161
+ */
162
+ readonly publishCleanupResource?: ((
163
+ resource: BrowserCleanupResourceIdentity,
164
+ ) => void) & {
165
+ /** Durably records that the closed browser and its private roots are idle. */
166
+ readonly markBrowserCleanupQuiescent?: (
167
+ resource: BrowserCleanupResourceIdentity,
168
+ ) => void;
169
+ /** Journals one exact root only after its identity-bound removal succeeds. */
170
+ readonly markBrowserCleanupRootRemoved?: (
171
+ resource: BrowserCleanupResourceIdentity,
172
+ root: BrowserCleanupResourceRoot,
173
+ ) => void;
174
+ };
175
+ readonly dependencies?: Partial<BrowserSessionDependencies>;
176
+ };
177
+
178
+ export type BrowserPrivateDirectoryIdentityV1 = {
179
+ readonly device: string;
180
+ readonly inode: string;
181
+ };
182
+
183
+ /** Legacy public name retained for v1 claim parsing compatibility. */
184
+ export type BrowserPrivateDirectoryIdentity =
185
+ BrowserPrivateDirectoryIdentityV1;
186
+
187
+ export type BrowserPrivateDirectoryIdentityV2 =
188
+ BrowserPrivateDirectoryIdentityV1 & {
189
+ readonly birthtimeNs: string;
190
+ /** Decimal POSIX permission bits. V2 admits only 0700 (`448`). */
191
+ readonly mode: "448";
192
+ readonly uid: string;
193
+ };
194
+
195
+ export type AgentBrowserControlWitnessV1 = {
196
+ readonly kind: "agent-browser-control-v1";
197
+ readonly version: "0.32.3";
198
+ readonly session: string;
199
+ readonly socketDirectory: string;
200
+ readonly daemonOwner: ProcessOwnerIdentity;
201
+ readonly engine: "chrome";
202
+ readonly launchHash: string;
203
+ readonly cdpUrl: string;
204
+ };
205
+
206
+ export type BrowserCleanupResourceIdentityV1 = {
207
+ readonly kind: "agent-browser-session-v1";
208
+ readonly recoveryHandle: string;
209
+ readonly session: string;
210
+ readonly socketDirectory: string;
211
+ readonly socketDirectoryIdentity: BrowserPrivateDirectoryIdentityV1;
212
+ readonly artifactsDirectory: string;
213
+ readonly artifactsDirectoryIdentity: BrowserPrivateDirectoryIdentityV1;
214
+ };
215
+
216
+ type BrowserCleanupResourceIdentityV2Base = {
217
+ readonly kind: "agent-browser-session-v2";
218
+ readonly recoveryHandle: string;
219
+ readonly session: string;
220
+ readonly socketDirectory: string;
221
+ readonly socketDirectoryIdentity: BrowserPrivateDirectoryIdentityV2;
222
+ readonly artifactsDirectory: string;
223
+ readonly artifactsDirectoryIdentity: BrowserPrivateDirectoryIdentityV2;
224
+ };
225
+
226
+ /**
227
+ * Durable browser cleanup progression. Prepared roots have no launch intent,
228
+ * launch-intent is persisted immediately before launch, and controlled binds
229
+ * one immutable daemon/browser/CDP witness. Progression is strictly monotonic.
230
+ */
231
+ export type BrowserCleanupResourceIdentityV2 =
232
+ BrowserCleanupResourceIdentityV2Base & (
233
+ | {
234
+ readonly phase: "prepared";
235
+ readonly control: null;
236
+ }
237
+ | {
238
+ readonly phase: "launch-intent";
239
+ readonly control: null;
240
+ }
241
+ | {
242
+ readonly phase: "controlled";
243
+ readonly control: AgentBrowserControlWitnessV1;
244
+ }
245
+ );
246
+
247
+ export type BrowserCleanupResourceIdentity =
248
+ | BrowserCleanupResourceIdentityV1
249
+ | BrowserCleanupResourceIdentityV2;
250
+
251
+ export type BrowserClosedCleanupEvidence = {
252
+ readonly kind: "agent-browser-closed-artifacts-v1";
253
+ readonly resource: BrowserCleanupResourceIdentity;
254
+ };
255
+
256
+ export class PreservedBrowserArtifactsError extends Error {
257
+ readonly recoveryHandle: string;
258
+ readonly cleanupEvidence?: BrowserClosedCleanupEvidence;
259
+
260
+ constructor(
261
+ message: string,
262
+ recoveryHandle: string,
263
+ cause: unknown,
264
+ cleanupEvidence?: BrowserClosedCleanupEvidence,
265
+ ) {
266
+ super(message, { cause });
267
+ this.name = "PreservedBrowserArtifactsError";
268
+ this.recoveryHandle = recoveryHandle;
269
+ if (cleanupEvidence !== undefined) {
270
+ this.cleanupEvidence = cleanupEvidence;
271
+ }
272
+ }
273
+ }
274
+
275
+ export class AgentBrowserLiveControlUnavailableError extends Error {
276
+ constructor() {
277
+ super("agent-browser live control identity is unavailable");
278
+ this.name = "AgentBrowserLiveControlUnavailableError";
279
+ }
280
+ }
281
+
282
+ type BrowserCommandStreamObservation = {
283
+ bytes: number;
284
+ naturalEof: boolean;
285
+ failed: boolean;
286
+ };
287
+
288
+ type BrowserCommandObservation = {
289
+ readonly elapsedMs: number;
290
+ readonly wrapperExited: boolean;
291
+ readonly wrapperExitCode: number | null;
292
+ readonly stdout: Readonly<BrowserCommandStreamObservation>;
293
+ readonly stderr: Readonly<BrowserCommandStreamObservation>;
294
+ };
295
+
296
+ export type BrowserCommandLifecycle = {
297
+ readonly schemaVersion: 1;
298
+ readonly stopReason: "timeout" | "cancelled" | "stdout-error" | "stderr-error" | "command-error";
299
+ readonly beforeStop: BrowserCommandObservation;
300
+ readonly terminal: BrowserCommandObservation & {
301
+ readonly originalGroupTerminationSucceeded: boolean;
302
+ readonly resourcesSettled: boolean;
303
+ };
304
+ };
305
+
306
+ // Content-free observations never grant cleanup authority or modify an Error.
307
+ const browserCommandObservations = new WeakMap<object, BrowserCommandLifecycle>();
308
+ export function browserCommandLifecycle(error: unknown): BrowserCommandLifecycle | null {
309
+ return typeof error === "object" && error !== null
310
+ ? browserCommandObservations.get(error) ?? null
311
+ : null;
312
+ }
313
+
314
+ class BrowserCommandCleanupError extends Error {
315
+ constructor(message: string, cause?: unknown) {
316
+ super(message, cause === undefined ? undefined : { cause });
317
+ this.name = "BrowserCommandCleanupError";
318
+ }
319
+ }
320
+
321
+ class AgentBrowserLifecycleCommandUnavailableError extends Error {
322
+ constructor(label: string) {
323
+ super(`agent-browser ${label} could not be verified`);
324
+ this.name = "AgentBrowserLifecycleCommandUnavailableError";
325
+ }
326
+ }
327
+
328
+ class AgentBrowserCleanupOwnerStillLiveError extends Error {
329
+ constructor() {
330
+ super("browser cleanup pinned owner is not quiescent");
331
+ this.name = "AgentBrowserCleanupOwnerStillLiveError";
332
+ }
333
+ }
334
+
335
+ class AgentBrowserPostCloseTransitionStillSettlingError extends Error {
336
+ constructor() {
337
+ super("browser cleanup post-close transition is still settling");
338
+ this.name = "AgentBrowserPostCloseTransitionStillSettlingError";
339
+ }
340
+ }
341
+
342
+ function recoveryHandleComponent(value: string): string {
343
+ return Buffer.from(value, "utf8").toString("base64url");
344
+ }
345
+
346
+ /**
347
+ * Fixed-order v1 recovery handle. Every value is unpadded base64url-encoded
348
+ * UTF-8, so valid POSIX and Windows paths remain unambiguous and transport-safe.
349
+ * An empty socket value means setup failed before a socket root was created.
350
+ */
351
+ export function browserRecoveryHandle(input: {
352
+ readonly session: string;
353
+ readonly configPath: string;
354
+ readonly socketDirectory: string | null;
355
+ readonly artifactsDirectory: string;
356
+ }): string {
357
+ return [
358
+ "v1",
359
+ `session=${recoveryHandleComponent(input.session)}`,
360
+ `config=${recoveryHandleComponent(input.configPath)}`,
361
+ `socket=${input.socketDirectory === null
362
+ ? ""
363
+ : recoveryHandleComponent(input.socketDirectory)}`,
364
+ `artifacts=${recoveryHandleComponent(input.artifactsDirectory)}`,
365
+ ].join(";");
366
+ }
367
+
368
+ const encodedRecoveryHandleComponentPattern = /^[A-Za-z0-9_-]{1,4096}$/u;
369
+ const browserSessionPattern = /^io-([1-9][0-9]{0,9})-[a-f0-9-]{12}$/u;
370
+
371
+ function decodeRecoveryHandleComponent(
372
+ value: string,
373
+ label: string,
374
+ ): string {
375
+ if (!encodedRecoveryHandleComponentPattern.test(value)) {
376
+ throw new Error(`browser recovery ${label} is malformed`);
377
+ }
378
+ const bytes = Buffer.from(value, "base64url");
379
+ if (bytes.toString("base64url") !== value) {
380
+ throw new Error(`browser recovery ${label} is not canonical`);
381
+ }
382
+ let decoded: string;
383
+ try {
384
+ decoded = new TextDecoder("utf-8", {
385
+ fatal: true,
386
+ ignoreBOM: true,
387
+ }).decode(bytes);
388
+ } catch {
389
+ throw new Error(`browser recovery ${label} is not UTF-8`);
390
+ }
391
+ if (decoded === "" || decoded.includes("\0")) {
392
+ throw new Error(`browser recovery ${label} is malformed`);
393
+ }
394
+ return decoded;
395
+ }
396
+
397
+ function canonicalAbsolutePath(value: string, label: string): string {
398
+ if (!isAbsolute(value) || resolve(value) !== value) {
399
+ throw new Error(`browser recovery ${label} is not canonical`);
400
+ }
401
+ return value;
402
+ }
403
+
404
+ export function parseBrowserRecoveryHandle(
405
+ value: string,
406
+ ): {
407
+ readonly session: string;
408
+ readonly configPath: string;
409
+ readonly socketDirectory: string;
410
+ readonly artifactsDirectory: string;
411
+ } {
412
+ if (value.length < 1 || value.length > 16 * 1024) {
413
+ throw new Error("browser recovery handle is malformed");
414
+ }
415
+ const parts = value.split(";");
416
+ if (
417
+ parts.length !== 5
418
+ || parts[0] !== "v1"
419
+ || !parts[1]?.startsWith("session=")
420
+ || !parts[2]?.startsWith("config=")
421
+ || !parts[3]?.startsWith("socket=")
422
+ || !parts[4]?.startsWith("artifacts=")
423
+ ) {
424
+ throw new Error("browser recovery handle is malformed");
425
+ }
426
+ const session = decodeRecoveryHandleComponent(
427
+ parts[1].slice("session=".length),
428
+ "session",
429
+ );
430
+ if (!browserSessionPattern.test(session)) {
431
+ throw new Error("browser recovery session is malformed");
432
+ }
433
+ const configPath = canonicalAbsolutePath(
434
+ decodeRecoveryHandleComponent(
435
+ parts[2].slice("config=".length),
436
+ "config path",
437
+ ),
438
+ "config path",
439
+ );
440
+ const encodedSocket = parts[3].slice("socket=".length);
441
+ if (encodedSocket === "") {
442
+ throw new Error("browser recovery socket directory is unavailable");
443
+ }
444
+ const socketDirectory = canonicalAbsolutePath(
445
+ decodeRecoveryHandleComponent(encodedSocket, "socket directory"),
446
+ "socket directory",
447
+ );
448
+ const artifactsDirectory = canonicalAbsolutePath(
449
+ decodeRecoveryHandleComponent(
450
+ parts[4].slice("artifacts=".length),
451
+ "artifacts directory",
452
+ ),
453
+ "artifacts directory",
454
+ );
455
+ if (
456
+ basename(socketDirectory).match(/^io-ab-[A-Za-z0-9_-]{6,64}$/u) === null
457
+ || dirname(socketDirectory) !== "/tmp"
458
+ || basename(artifactsDirectory).match(
459
+ /^io-browser-[A-Za-z0-9_-]{6,64}$/u,
460
+ ) === null
461
+ || dirname(artifactsDirectory) !== resolve(tmpdir())
462
+ || configPath !== join(artifactsDirectory, "agent-browser.json")
463
+ || dirname(configPath) !== artifactsDirectory
464
+ ) {
465
+ throw new Error("browser recovery handle does not bind owned private roots");
466
+ }
467
+ return Object.freeze({
468
+ session,
469
+ configPath,
470
+ socketDirectory,
471
+ artifactsDirectory,
472
+ });
473
+ }
474
+
475
+ const browserIdentityDecimalPattern = /^(?:0|[1-9][0-9]{0,39})$/u;
476
+ const processIdentityDigestPattern = /^[a-f0-9]{64}$/u;
477
+ const agentBrowserBoundaryNoncePattern = /^[a-f0-9]{32}$/u;
478
+ const agentBrowserLaunchHashPattern = /^(?:0|[1-9][0-9]{0,19})$/u;
479
+ const maximumAgentBrowserControlLaunchHash = (1n << 64n) - 1n;
480
+
481
+ function browserIdentityRecord(value: unknown, label: string): JsonRecord {
482
+ if (
483
+ typeof value !== "object"
484
+ || value === null
485
+ || Array.isArray(value)
486
+ || nodeTypes.isProxy(value)
487
+ ) throw new Error(`${label} is malformed`);
488
+ const prototype: unknown = Object.getPrototypeOf(value) as unknown;
489
+ if (prototype !== Object.prototype && prototype !== null) {
490
+ throw new Error(`${label} is malformed`);
491
+ }
492
+ const descriptors = Object.getOwnPropertyDescriptors(value);
493
+ const output: JsonRecord = {};
494
+ for (const key of Reflect.ownKeys(descriptors)) {
495
+ if (typeof key !== "string") throw new Error(`${label} is malformed`);
496
+ const descriptor = descriptors[key];
497
+ if (
498
+ descriptor === undefined
499
+ || !descriptor.enumerable
500
+ || !("value" in descriptor)
501
+ || /[\u0000-\u001f\u007f-\u009f]/u.test(key)
502
+ ) throw new Error(`${label} is malformed`);
503
+ output[key] = descriptor.value as unknown;
504
+ }
505
+ return output;
506
+ }
507
+
508
+ function browserIdentityExactKeys(
509
+ value: JsonRecord,
510
+ expected: readonly string[],
511
+ label: string,
512
+ ): void {
513
+ const actual = Object.keys(value).sort();
514
+ const wanted = [...expected].sort();
515
+ if (
516
+ actual.length !== wanted.length
517
+ || actual.some((key, index) => key !== wanted[index])
518
+ ) throw new Error(`${label} is malformed`);
519
+ }
520
+
521
+ function unwrapAgentBrowserIdentityResult(
522
+ value: unknown,
523
+ label: string,
524
+ ): {
525
+ readonly data: unknown;
526
+ readonly success: unknown;
527
+ } {
528
+ const root = browserIdentityRecord(value, label);
529
+ const keys = Object.keys(root).sort();
530
+ if (keys.length === 2 && keys[0] === "data" && keys[1] === "success") {
531
+ return Object.freeze({
532
+ data: root.data,
533
+ success: root.success,
534
+ });
535
+ }
536
+ browserIdentityExactKeys(
537
+ root,
538
+ ["_boundary", "data", "error", "success"],
539
+ label,
540
+ );
541
+ const boundary = browserIdentityRecord(root._boundary, `${label} boundary`);
542
+ browserIdentityExactKeys(boundary, ["nonce", "origin"], `${label} boundary`);
543
+ if (
544
+ typeof boundary.nonce !== "string"
545
+ || !agentBrowserBoundaryNoncePattern.test(boundary.nonce)
546
+ || boundary.origin !== "unknown"
547
+ ) throw new Error(`${label} is malformed`);
548
+ return Object.freeze({
549
+ data: root.data,
550
+ success: root.success,
551
+ });
552
+ }
553
+
554
+ function browserIdentityDecimal(value: unknown, label: string): string {
555
+ if (
556
+ typeof value !== "string"
557
+ || !browserIdentityDecimalPattern.test(value)
558
+ ) throw new Error(`${label} is malformed`);
559
+ return value;
560
+ }
561
+
562
+ function parseBrowserPrivateDirectoryIdentityV1(
563
+ value: unknown,
564
+ label: string,
565
+ ): BrowserPrivateDirectoryIdentityV1 {
566
+ const identity = browserIdentityRecord(value, label);
567
+ browserIdentityExactKeys(identity, ["device", "inode"], label);
568
+ return Object.freeze({
569
+ device: browserIdentityDecimal(identity.device, `${label} device`),
570
+ inode: browserIdentityDecimal(identity.inode, `${label} inode`),
571
+ });
572
+ }
573
+
574
+ function parseBrowserPrivateDirectoryIdentityV2(
575
+ value: unknown,
576
+ label: string,
577
+ ): BrowserPrivateDirectoryIdentityV2 {
578
+ const identity = browserIdentityRecord(value, label);
579
+ browserIdentityExactKeys(
580
+ identity,
581
+ ["birthtimeNs", "device", "inode", "mode", "uid"],
582
+ label,
583
+ );
584
+ const birthtimeNs = browserIdentityDecimal(
585
+ identity.birthtimeNs,
586
+ `${label} birth time`,
587
+ );
588
+ if (birthtimeNs === "0" || identity.mode !== "448") {
589
+ throw new Error(`${label} is malformed`);
590
+ }
591
+ return Object.freeze({
592
+ device: browserIdentityDecimal(identity.device, `${label} device`),
593
+ inode: browserIdentityDecimal(identity.inode, `${label} inode`),
594
+ birthtimeNs,
595
+ mode: "448",
596
+ uid: browserIdentityDecimal(identity.uid, `${label} owner`),
597
+ });
598
+ }
599
+
600
+ function parseBrowserProcessOwner(value: unknown): ProcessOwnerIdentity {
601
+ const owner = browserIdentityRecord(value, "browser cleanup daemon owner");
602
+ browserIdentityExactKeys(
603
+ owner,
604
+ ["bootId", "pid", "processStartId"],
605
+ "browser cleanup daemon owner",
606
+ );
607
+ if (
608
+ typeof owner.pid !== "number"
609
+ || !Number.isSafeInteger(owner.pid)
610
+ || owner.pid < 1
611
+ || typeof owner.bootId !== "string"
612
+ || !processIdentityDigestPattern.test(owner.bootId)
613
+ || typeof owner.processStartId !== "string"
614
+ || !processIdentityDigestPattern.test(owner.processStartId)
615
+ ) throw new Error("browser cleanup daemon owner is malformed");
616
+ return Object.freeze({
617
+ pid: owner.pid,
618
+ bootId: owner.bootId,
619
+ processStartId: owner.processStartId,
620
+ });
621
+ }
622
+
623
+ function canonicalAgentBrowserLaunchHash(value: unknown): string {
624
+ if (
625
+ typeof value !== "string"
626
+ || !agentBrowserLaunchHashPattern.test(value)
627
+ || BigInt(value) > maximumAgentBrowserControlLaunchHash
628
+ ) throw new Error("browser cleanup launch identity is malformed");
629
+ return value;
630
+ }
631
+
632
+ function literalLoopbackAgentBrowserCdpUrl(value: unknown): string {
633
+ let cdpUrl: string;
634
+ try {
635
+ cdpUrl = localBrowserCdpUrl(value);
636
+ } catch {
637
+ throw new Error("browser cleanup control witness is malformed");
638
+ }
639
+ const hostname = new URL(cdpUrl).hostname;
640
+ if (hostname !== "127.0.0.1" && hostname !== "[::1]") {
641
+ throw new Error("browser cleanup control witness is malformed");
642
+ }
643
+ return cdpUrl;
644
+ }
645
+
646
+ function parseAgentBrowserControlWitness(
647
+ value: unknown,
648
+ ): AgentBrowserControlWitnessV1 {
649
+ const control = browserIdentityRecord(
650
+ value,
651
+ "browser cleanup control witness",
652
+ );
653
+ browserIdentityExactKeys(control, [
654
+ "cdpUrl",
655
+ "daemonOwner",
656
+ "engine",
657
+ "kind",
658
+ "launchHash",
659
+ "session",
660
+ "socketDirectory",
661
+ "version",
662
+ ], "browser cleanup control witness");
663
+ if (
664
+ control.kind !== "agent-browser-control-v1"
665
+ || control.version !== "0.32.3"
666
+ || typeof control.session !== "string"
667
+ || !browserSessionPattern.test(control.session)
668
+ || typeof control.socketDirectory !== "string"
669
+ || control.engine !== "chrome"
670
+ || typeof control.cdpUrl !== "string"
671
+ ) throw new Error("browser cleanup control witness is malformed");
672
+ const cdpUrl = literalLoopbackAgentBrowserCdpUrl(control.cdpUrl);
673
+ if (cdpUrl !== control.cdpUrl) {
674
+ throw new Error("browser cleanup control witness is malformed");
675
+ }
676
+ return Object.freeze({
677
+ kind: "agent-browser-control-v1",
678
+ version: "0.32.3",
679
+ session: control.session,
680
+ socketDirectory: canonicalAbsolutePath(
681
+ control.socketDirectory,
682
+ "control socket directory",
683
+ ),
684
+ daemonOwner: parseBrowserProcessOwner(control.daemonOwner),
685
+ engine: "chrome",
686
+ launchHash: canonicalAgentBrowserLaunchHash(control.launchHash),
687
+ cdpUrl,
688
+ });
689
+ }
690
+
691
+ function browserCleanupResourceBase(value: JsonRecord): {
692
+ readonly recoveryHandle: string;
693
+ readonly session: string;
694
+ readonly socketDirectory: string;
695
+ readonly artifactsDirectory: string;
696
+ } {
697
+ if (
698
+ typeof value.recoveryHandle !== "string"
699
+ || typeof value.session !== "string"
700
+ || typeof value.socketDirectory !== "string"
701
+ || typeof value.artifactsDirectory !== "string"
702
+ ) throw new Error("browser cleanup resource identity is malformed");
703
+ const recovery = parseBrowserRecoveryHandle(value.recoveryHandle);
704
+ if (
705
+ recovery.session !== value.session
706
+ || recovery.socketDirectory !== value.socketDirectory
707
+ || recovery.artifactsDirectory !== value.artifactsDirectory
708
+ ) throw new Error("browser cleanup resource does not match its recovery handle");
709
+ return {
710
+ recoveryHandle: value.recoveryHandle,
711
+ session: recovery.session,
712
+ socketDirectory: recovery.socketDirectory,
713
+ artifactsDirectory: recovery.artifactsDirectory,
714
+ };
715
+ }
716
+
717
+ export function parseBrowserCleanupResourceIdentity(
718
+ value: unknown,
719
+ ): BrowserCleanupResourceIdentity {
720
+ const identity = browserIdentityRecord(
721
+ value,
722
+ "browser cleanup resource identity",
723
+ );
724
+ const commonKeys = [
725
+ "artifactsDirectory",
726
+ "artifactsDirectoryIdentity",
727
+ "kind",
728
+ "recoveryHandle",
729
+ "session",
730
+ "socketDirectory",
731
+ "socketDirectoryIdentity",
732
+ ] as const;
733
+ if (identity.kind === "agent-browser-session-v1") {
734
+ browserIdentityExactKeys(identity, commonKeys, "browser cleanup resource identity");
735
+ const base = browserCleanupResourceBase(identity);
736
+ return Object.freeze({
737
+ kind: "agent-browser-session-v1",
738
+ ...base,
739
+ socketDirectoryIdentity: parseBrowserPrivateDirectoryIdentityV1(
740
+ identity.socketDirectoryIdentity,
741
+ "browser cleanup socket identity",
742
+ ),
743
+ artifactsDirectoryIdentity: parseBrowserPrivateDirectoryIdentityV1(
744
+ identity.artifactsDirectoryIdentity,
745
+ "browser cleanup artifacts identity",
746
+ ),
747
+ });
748
+ }
749
+ if (identity.kind !== "agent-browser-session-v2") {
750
+ throw new Error("browser cleanup resource identity kind is unsupported");
751
+ }
752
+ browserIdentityExactKeys(
753
+ identity,
754
+ [...commonKeys, "control", "phase"],
755
+ "browser cleanup resource identity",
756
+ );
757
+ const base = browserCleanupResourceBase(identity);
758
+ const roots = Object.freeze({
759
+ kind: "agent-browser-session-v2",
760
+ ...base,
761
+ socketDirectoryIdentity: parseBrowserPrivateDirectoryIdentityV2(
762
+ identity.socketDirectoryIdentity,
763
+ "browser cleanup socket identity",
764
+ ),
765
+ artifactsDirectoryIdentity: parseBrowserPrivateDirectoryIdentityV2(
766
+ identity.artifactsDirectoryIdentity,
767
+ "browser cleanup artifacts identity",
768
+ ),
769
+ });
770
+ if (identity.phase === "prepared" || identity.phase === "launch-intent") {
771
+ if (identity.control !== null) {
772
+ throw new Error("browser cleanup phase and control witness are inconsistent");
773
+ }
774
+ return Object.freeze({
775
+ ...roots,
776
+ phase: identity.phase,
777
+ control: null,
778
+ });
779
+ }
780
+ if (identity.phase !== "controlled" || identity.control === null) {
781
+ throw new Error("browser cleanup phase is malformed");
782
+ }
783
+ const control = parseAgentBrowserControlWitness(identity.control);
784
+ if (
785
+ control.session !== base.session
786
+ || control.socketDirectory !== base.socketDirectory
787
+ ) throw new Error("browser cleanup control witness changed resource identity");
788
+ return Object.freeze({
789
+ ...roots,
790
+ phase: "controlled",
791
+ control,
792
+ });
793
+ }
794
+
795
+ function privateBrowserDirectoryIdentityV2(
796
+ path: string,
797
+ ): BrowserPrivateDirectoryIdentityV2 {
798
+ const stats = lstatSync(path, { bigint: true });
799
+ const currentUid = process.getuid?.();
800
+ if (
801
+ stats.isSymbolicLink()
802
+ || !stats.isDirectory()
803
+ || currentUid === undefined
804
+ || stats.uid !== BigInt(currentUid)
805
+ || (stats.mode & 0o777n) !== 0o700n
806
+ || stats.birthtimeNs <= 0n
807
+ ) {
808
+ throw new Error("browser cleanup resource is not a recoverable private directory");
809
+ }
810
+ return Object.freeze({
811
+ device: stats.dev.toString(),
812
+ inode: stats.ino.toString(),
813
+ birthtimeNs: stats.birthtimeNs.toString(),
814
+ mode: "448",
815
+ uid: stats.uid.toString(),
816
+ });
817
+ }
818
+
819
+ function sameBrowserIdentity(left: unknown, right: unknown): boolean {
820
+ return JSON.stringify(left) === JSON.stringify(right);
821
+ }
822
+
823
+ function sameBrowserCleanupResourceBase(
824
+ left: BrowserCleanupResourceIdentity,
825
+ right: BrowserCleanupResourceIdentity,
826
+ ): boolean {
827
+ return left.recoveryHandle === right.recoveryHandle
828
+ && left.session === right.session
829
+ && left.socketDirectory === right.socketDirectory
830
+ && left.artifactsDirectory === right.artifactsDirectory;
831
+ }
832
+
833
+ /** True only for equality or one strictly stronger, immutable browser pin. */
834
+ export function browserCleanupResourceExtends(
835
+ current: BrowserCleanupResourceIdentity,
836
+ next: BrowserCleanupResourceIdentity,
837
+ ): boolean {
838
+ const left = parseBrowserCleanupResourceIdentity(current);
839
+ const right = parseBrowserCleanupResourceIdentity(next);
840
+ if (!sameBrowserCleanupResourceBase(left, right)) return false;
841
+ if (left.kind === "agent-browser-session-v1") {
842
+ if (right.kind === "agent-browser-session-v1") {
843
+ return sameBrowserIdentity(left, right);
844
+ }
845
+ const matchesLegacyRoots = right.control !== null
846
+ && left.socketDirectoryIdentity.device
847
+ === right.socketDirectoryIdentity.device
848
+ && left.socketDirectoryIdentity.inode
849
+ === right.socketDirectoryIdentity.inode
850
+ && left.artifactsDirectoryIdentity.device
851
+ === right.artifactsDirectoryIdentity.device
852
+ && left.artifactsDirectoryIdentity.inode
853
+ === right.artifactsDirectoryIdentity.inode;
854
+ if (!matchesLegacyRoots) return false;
855
+ try {
856
+ assertBrowserCleanupResourceRootsMatch(right);
857
+ return true;
858
+ } catch {
859
+ return false;
860
+ }
861
+ }
862
+ if (
863
+ right.kind !== "agent-browser-session-v2"
864
+ || !sameBrowserIdentity(
865
+ left.socketDirectoryIdentity,
866
+ right.socketDirectoryIdentity,
867
+ )
868
+ || !sameBrowserIdentity(
869
+ left.artifactsDirectoryIdentity,
870
+ right.artifactsDirectoryIdentity,
871
+ )
872
+ ) return false;
873
+ if (left.phase === "prepared") {
874
+ return right.phase === "prepared" || right.phase === "launch-intent";
875
+ }
876
+ if (left.phase === "launch-intent") {
877
+ return right.phase === "launch-intent" || right.phase === "controlled";
878
+ }
879
+ return right.phase === "controlled"
880
+ && sameBrowserIdentity(left.control, right.control);
881
+ }
882
+
883
+ export type BrowserCleanupResourceRoot = "socket" | "artifacts";
884
+ export type BrowserCleanupResourceRootStatus = "match" | "absent" | "conflict";
885
+
886
+ /** Classify one exact private root without treating a replacement as absent. */
887
+ export function browserCleanupResourceRootStatus(
888
+ value: BrowserCleanupResourceIdentityV2,
889
+ root: BrowserCleanupResourceRoot,
890
+ ): BrowserCleanupResourceRootStatus {
891
+ const resource = parseBrowserCleanupResourceIdentity(value);
892
+ if (resource.kind !== "agent-browser-session-v2") {
893
+ throw new Error("browser cleanup resource does not have recoverable roots");
894
+ }
895
+ const path = root === "socket"
896
+ ? resource.socketDirectory
897
+ : resource.artifactsDirectory;
898
+ const expected = root === "socket"
899
+ ? resource.socketDirectoryIdentity
900
+ : resource.artifactsDirectoryIdentity;
901
+ let actual: BrowserPrivateDirectoryIdentityV2;
902
+ try {
903
+ actual = privateBrowserDirectoryIdentityV2(path);
904
+ } catch (error) {
905
+ return (
906
+ typeof error === "object"
907
+ && error !== null
908
+ && "code" in error
909
+ && error.code === "ENOENT"
910
+ ) ? "absent" : "conflict";
911
+ }
912
+ return sameBrowserIdentity(actual, expected) ? "match" : "conflict";
913
+ }
914
+
915
+ export function assertBrowserCleanupResourceRootsMatch(
916
+ value: BrowserCleanupResourceIdentityV2,
917
+ ): void {
918
+ const resource = parseBrowserCleanupResourceIdentity(value);
919
+ if (resource.kind !== "agent-browser-session-v2") {
920
+ throw new Error("browser cleanup resource does not have recoverable roots");
921
+ }
922
+ if (
923
+ browserCleanupResourceRootStatus(resource, "socket") !== "match"
924
+ || browserCleanupResourceRootStatus(resource, "artifacts") !== "match"
925
+ ) throw new Error("browser cleanup private root identity changed");
926
+ }
927
+
928
+ function browserCleanupResourceIdentity(input: {
929
+ readonly recoveryHandle: string;
930
+ readonly session: string;
931
+ readonly socketDirectory: string;
932
+ readonly artifactsDirectory: string;
933
+ }): BrowserCleanupResourceIdentityV2 {
934
+ const parsed = parseBrowserRecoveryHandle(input.recoveryHandle);
935
+ if (
936
+ parsed.session !== input.session
937
+ || parsed.socketDirectory !== input.socketDirectory
938
+ || parsed.artifactsDirectory !== input.artifactsDirectory
939
+ ) {
940
+ throw new Error("browser cleanup resource does not match its recovery handle");
941
+ }
942
+ return parseBrowserCleanupResourceIdentity({
943
+ kind: "agent-browser-session-v2",
944
+ recoveryHandle: input.recoveryHandle,
945
+ session: parsed.session,
946
+ socketDirectory: parsed.socketDirectory,
947
+ socketDirectoryIdentity: privateBrowserDirectoryIdentityV2(
948
+ parsed.socketDirectory,
949
+ ),
950
+ artifactsDirectory: parsed.artifactsDirectory,
951
+ artifactsDirectoryIdentity: privateBrowserDirectoryIdentityV2(
952
+ parsed.artifactsDirectory,
953
+ ),
954
+ phase: "prepared",
955
+ control: null,
956
+ }) as BrowserCleanupResourceIdentityV2;
957
+ }
958
+
959
+ /**
960
+ * Observe a browser-backed operation until teardown finishes. Ordinary
961
+ * operation failures are already reported by the provider; only an unsafe
962
+ * cleanup rejects this distinct quiescence barrier.
963
+ */
964
+ export function browserCleanupBarrier(
965
+ operation: Promise<unknown>,
966
+ ): Promise<void> {
967
+ return operation.then(
968
+ () => undefined,
969
+ (error: unknown) => {
970
+ if (error instanceof PreservedBrowserArtifactsError) throw error;
971
+ },
972
+ );
973
+ }
974
+
975
+ export function classifyBrowserProcessGroupProbe(
976
+ status: number | null,
977
+ stderr: string,
978
+ ): "live" | "gone" | "unknown" {
979
+ if (status === 0) return "live";
980
+ if (status === 1 && stderr.includes("No such process")) return "gone";
981
+ if (status === 1 && stderr.includes("Operation not permitted")) return "live";
982
+ return "unknown";
983
+ }
984
+
985
+ const inheritedProxyKeys = new Set([
986
+ "ALL_PROXY",
987
+ "HTTP_PROXY",
988
+ "HTTPS_PROXY",
989
+ "NO_PROXY",
990
+ "all_proxy",
991
+ "http_proxy",
992
+ "https_proxy",
993
+ "no_proxy",
994
+ ]);
995
+
996
+ /** Exact policy vocabulary required by pinned agent-browser 0.32.3. */
997
+ export const runtimeBrowserPolicyActions = [
998
+ "launch", "navigate", "snapshot", "click", "fill", "scroll", "wait", "read", "get", "interact", "state",
999
+ "type", "hover", "focus", "press", "url", "inputvalue", "waitfortext", "waitforurl", "cookies_set", "close",
1000
+ "session_info", "cdp_url",
1001
+ "upload", "select", "check", "uncheck", "ischecked",
1002
+ "getbyrole", "getbytext", "getbylabel", "getbyplaceholder", "getbyalttext", "getbytitle", "getbytestid",
1003
+ ] as const;
1004
+
1005
+ export function agentBrowserCommand(): readonly string[] {
1006
+ return packageAgentBrowserCommand();
1007
+ }
1008
+
1009
+ export const ownedChromeOnboardingArguments = Object.freeze([
1010
+ "--no-first-run",
1011
+ "--no-default-browser-check",
1012
+ ] as const);
1013
+
1014
+ const reviewedOwnedChromeArguments = new Set([
1015
+ "--profile-directory=Default",
1016
+ "--disable-quic",
1017
+ "--disable-dns-prefetch",
1018
+ "--disable-background-networking",
1019
+ "--disable-component-update",
1020
+ "--disable-default-apps",
1021
+ "--disable-sync",
1022
+ "--disable-features=AsyncDns",
1023
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
1024
+ "--proxy-bypass-list=<-loopback>",
1025
+ ]);
1026
+
1027
+ /**
1028
+ * Build one pinned --args value for a Ghostget-owned fresh or cloned Chrome.
1029
+ * Callers supply only code-owned arguments; Ghostget never accepts raw launch
1030
+ * arguments from manifests, operation input, auth locators, or the CLI.
1031
+ */
1032
+ export function ownedChromeLaunchArguments(
1033
+ additional: readonly string[] = [],
1034
+ ): string {
1035
+ if (
1036
+ additional.some((argument) =>
1037
+ !reviewedOwnedChromeArguments.has(argument)
1038
+ || /[\0\r\n,]/u.test(argument))
1039
+ ) {
1040
+ throw new Error("owned Chrome launch arguments are not reviewed");
1041
+ }
1042
+ return [...new Set([
1043
+ ...additional,
1044
+ ...ownedChromeOnboardingArguments,
1045
+ ])].join("\n");
1046
+ }
1047
+
1048
+ export function ownedBrowserProxyArguments(
1049
+ proxyUrl: string,
1050
+ profileDirectory?: "Default",
1051
+ ): readonly string[] {
1052
+ const generated = browserProxyArguments(proxyUrl, profileDirectory);
1053
+ if (
1054
+ generated.length !== 4
1055
+ || generated[0] !== "--proxy"
1056
+ || generated[1] !== proxyUrl
1057
+ || generated[2] !== "--args"
1058
+ || typeof generated[3] !== "string"
1059
+ ) {
1060
+ throw new Error("pinned browser proxy arguments are malformed");
1061
+ }
1062
+ return Object.freeze([
1063
+ "--proxy",
1064
+ proxyUrl,
1065
+ "--args",
1066
+ ownedChromeLaunchArguments(generated[3].split("\n")),
1067
+ ]);
1068
+ }
1069
+
1070
+ /**
1071
+ * Add the one Ghostget-owned MV3 guard to the already reviewed proxy launch.
1072
+ * The path is derived from a private derivation root; caller-selected Chrome
1073
+ * arguments remain impossible.
1074
+ */
1075
+ export function ownedDerivationGuardBrowserArguments(
1076
+ proxyUrl: string,
1077
+ extensionDirectory: string,
1078
+ ): readonly string[] {
1079
+ if (
1080
+ !isAbsolute(extensionDirectory)
1081
+ || basename(extensionDirectory) !== "network-guard-extension"
1082
+ || /[\0\r\n,]/u.test(extensionDirectory)
1083
+ ) throw new Error("owned derivation guard extension path is invalid");
1084
+ const base = ownedBrowserProxyArguments(proxyUrl);
1085
+ if (base.length !== 4 || base[0] !== "--proxy" || base[2] !== "--args") {
1086
+ throw new Error("owned derivation proxy arguments changed shape");
1087
+ }
1088
+ const chromeArguments = base[3];
1089
+ if (typeof chromeArguments !== "string") {
1090
+ throw new Error("owned derivation proxy arguments changed shape");
1091
+ }
1092
+ return Object.freeze([
1093
+ base[0],
1094
+ base[1] as string,
1095
+ base[2],
1096
+ [
1097
+ chromeArguments,
1098
+ `--disable-extensions-except=${extensionDirectory}`,
1099
+ `--load-extension=${extensionDirectory}`,
1100
+ ].join("\n"),
1101
+ ]);
1102
+ }
1103
+
1104
+ export function isolatedEnvironment(
1105
+ socketDirectory: string,
1106
+ inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,
1107
+ ): Readonly<Record<string, string>> {
1108
+ const output: Record<string, string> = {};
1109
+ for (const [key, value] of Object.entries(inheritedEnvironment)) {
1110
+ if (value === undefined || key.startsWith("AGENT_BROWSER_") || inheritedProxyKeys.has(key)) continue;
1111
+ output[key] = value;
1112
+ }
1113
+ output.AGENT_BROWSER_SOCKET_DIR = socketDirectory;
1114
+ return output;
1115
+ }
1116
+
1117
+ const BROWSER_COMMAND_RESOURCE_SETTLEMENT_TIMEOUT_MS = 1_000;
1118
+
1119
+ async function promiseSettlesWithin(
1120
+ promise: Promise<unknown>,
1121
+ maximumMs: number,
1122
+ ): Promise<boolean> {
1123
+ let timer: ReturnType<typeof setTimeout> | undefined;
1124
+ try {
1125
+ return await Promise.race([
1126
+ promise.then(
1127
+ () => true as const,
1128
+ () => true as const,
1129
+ ),
1130
+ new Promise<false>((resolve) => {
1131
+ timer = setTimeout(() => resolve(false), maximumMs);
1132
+ }),
1133
+ ]);
1134
+ } finally {
1135
+ if (timer !== undefined) clearTimeout(timer);
1136
+ }
1137
+ }
1138
+
1139
+ async function readBoundedStream(
1140
+ stream: ReadableStream<Uint8Array>,
1141
+ maxBytes: number,
1142
+ cancellation: AbortSignal,
1143
+ observation: BrowserCommandStreamObservation,
1144
+ ): Promise<string> {
1145
+ const bytes = new BoundedByteBuffer(maxBytes);
1146
+ const reader = stream.getReader();
1147
+ const cancel = (): void => {
1148
+ void reader.cancel("browser command was terminated").catch(() => undefined);
1149
+ };
1150
+ cancellation.addEventListener("abort", cancel, { once: true });
1151
+ try {
1152
+ if (cancellation.aborted) cancel();
1153
+ for (;;) {
1154
+ const result = await reader.read();
1155
+ if (result.done) {
1156
+ // Cancellation may resolve read() with done:true; that is not natural EOF.
1157
+ observation.naturalEof = !cancellation.aborted;
1158
+ break;
1159
+ }
1160
+ observation.bytes = Math.min(Number.MAX_SAFE_INTEGER, observation.bytes + result.value.byteLength);
1161
+ if (!bytes.append(result.value)) {
1162
+ throw new Error(`process output exceeded ${maxBytes} bytes`);
1163
+ }
1164
+ }
1165
+ } finally {
1166
+ cancellation.removeEventListener("abort", cancel);
1167
+ reader.releaseLock();
1168
+ }
1169
+ return new TextDecoder().decode(bytes.toUint8Array());
1170
+ }
1171
+
1172
+ export async function runCommand(
1173
+ command: readonly string[],
1174
+ options: {
1175
+ readonly cwd: string;
1176
+ readonly environment: Readonly<Record<string, string>>;
1177
+ readonly timeoutMs: number;
1178
+ readonly maxOutputBytes: number;
1179
+ readonly stdin?: string;
1180
+ readonly signal?: AbortSignal;
1181
+ },
1182
+ ): Promise<CommandResult> {
1183
+ if (process.platform === "win32") {
1184
+ throw new Error(
1185
+ "contained browser commands require process-tree containment that is unavailable on Windows",
1186
+ );
1187
+ }
1188
+ const isCancelled = (): boolean => options.signal?.aborted === true;
1189
+ if (isCancelled()) {
1190
+ throw new Error("agent-browser command was cancelled");
1191
+ }
1192
+ const startedAt = performance.now();
1193
+ const ownsProcessGroup = true;
1194
+ const child = Bun.spawn([...command], {
1195
+ cwd: options.cwd,
1196
+ detached: ownsProcessGroup,
1197
+ env: options.environment,
1198
+ stdin: options.stdin === undefined ? "ignore" : new Blob([options.stdin]),
1199
+ stdout: "pipe",
1200
+ stderr: "pipe",
1201
+ });
1202
+ let childExited = false;
1203
+ let wrapperExitCode: number | null = null;
1204
+ const exited = child.exited.then((exitCode) => {
1205
+ childExited = true;
1206
+ wrapperExitCode = exitCode;
1207
+ return exitCode;
1208
+ });
1209
+ const stdoutObservation: BrowserCommandStreamObservation = { bytes: 0, naturalEof: false, failed: false };
1210
+ const stderrObservation: BrowserCommandStreamObservation = { bytes: 0, naturalEof: false, failed: false };
1211
+ const observe = (): BrowserCommandObservation => Object.freeze({
1212
+ elapsedMs: Math.max(0, performance.now() - startedAt),
1213
+ wrapperExited: childExited,
1214
+ wrapperExitCode,
1215
+ stdout: Object.freeze({ ...stdoutObservation }),
1216
+ stderr: Object.freeze({ ...stderrObservation }),
1217
+ });
1218
+ let firstStop: { readonly reason: BrowserCommandLifecycle["stopReason"]; readonly observation: BrowserCommandObservation } | null = null;
1219
+ const diagnosed = <E extends Error>(error: E, originalGroupTerminationSucceeded: boolean, resourcesSettled: boolean): E => {
1220
+ try {
1221
+ if (firstStop !== null) browserCommandObservations.set(error, Object.freeze({
1222
+ schemaVersion: 1,
1223
+ stopReason: firstStop.reason,
1224
+ beforeStop: firstStop.observation,
1225
+ terminal: Object.freeze({ ...observe(), originalGroupTerminationSucceeded, resourcesSettled }),
1226
+ }));
1227
+ } catch { /* Best-effort observation must never replace the selected failure. */ }
1228
+ return error;
1229
+ };
1230
+ const outputCancellation = new AbortController();
1231
+ const signalProcessTree = (signal: "SIGTERM" | "SIGKILL"): void => {
1232
+ if (ownsProcessGroup) {
1233
+ try {
1234
+ process.kill(-child.pid, signal);
1235
+ return;
1236
+ } catch {
1237
+ // The group may have exited between inspection and signaling.
1238
+ }
1239
+ }
1240
+ try {
1241
+ child.kill(signal);
1242
+ } catch {
1243
+ // Killing an already-exited direct child is harmless.
1244
+ }
1245
+ };
1246
+ const processTreeIsLive = (): boolean => {
1247
+ if (!ownsProcessGroup) return !childExited;
1248
+ try {
1249
+ process.kill(-child.pid, 0);
1250
+ return true;
1251
+ } catch (error) {
1252
+ if (
1253
+ typeof error === "object"
1254
+ && error !== null
1255
+ && "code" in error
1256
+ && error.code === "ESRCH"
1257
+ ) return false;
1258
+ if (
1259
+ typeof error === "object"
1260
+ && error !== null
1261
+ && "code" in error
1262
+ && error.code === "EPERM"
1263
+ ) {
1264
+ const probe = spawnSync(
1265
+ "/bin/kill",
1266
+ ["-0", `-${child.pid}`],
1267
+ {
1268
+ encoding: "utf8",
1269
+ env: {
1270
+ LANG: "C",
1271
+ LC_ALL: "C",
1272
+ NODE_ENV: "production",
1273
+ PATH: "/usr/bin:/bin",
1274
+ },
1275
+ },
1276
+ );
1277
+ if (probe.error === undefined) {
1278
+ const result = classifyBrowserProcessGroupProbe(
1279
+ probe.status,
1280
+ probe.stderr,
1281
+ );
1282
+ if (result === "live") return true;
1283
+ if (result === "gone") return false;
1284
+ }
1285
+ }
1286
+ // An ambiguous process-group probe must never be treated as safe exit.
1287
+ return true;
1288
+ }
1289
+ };
1290
+ const waitUntilStopped = async (maximumMs: number): Promise<boolean> => {
1291
+ const deadline = performance.now() + maximumMs;
1292
+ for (;;) {
1293
+ if (!processTreeIsLive()) return true;
1294
+ if (performance.now() >= deadline) return false;
1295
+ await new Promise<void>((resolve) => {
1296
+ setTimeout(resolve, 10);
1297
+ });
1298
+ }
1299
+ };
1300
+ const terminate = async (): Promise<void> => {
1301
+ signalProcessTree("SIGTERM");
1302
+ if (!await waitUntilStopped(1_000)) {
1303
+ signalProcessTree("SIGKILL");
1304
+ if (!await waitUntilStopped(1_000)) {
1305
+ throw new BrowserCommandCleanupError(
1306
+ "agent-browser process group survived forced termination",
1307
+ );
1308
+ }
1309
+ }
1310
+ };
1311
+ const state: {
1312
+ failure: Error | null;
1313
+ termination: Promise<void> | null;
1314
+ } = {
1315
+ failure: null,
1316
+ termination: null,
1317
+ };
1318
+ let rejectForStop: ((error: Error) => void) | undefined;
1319
+ const stopped = new Promise<never>((_resolve, reject) => {
1320
+ rejectForStop = reject;
1321
+ });
1322
+ const requestStop = (error: Error, reason: BrowserCommandLifecycle["stopReason"] = "command-error"): void => {
1323
+ if (state.failure !== null) return;
1324
+ state.failure = error;
1325
+ try { firstStop = { reason, observation: observe() }; } catch { /* Preserve failure and cleanup. */ }
1326
+ outputCancellation.abort();
1327
+ state.termination = terminate();
1328
+ void state.termination.catch(() => undefined);
1329
+ rejectForStop?.(error);
1330
+ };
1331
+ const onAbort = (): void => {
1332
+ requestStop(new Error("agent-browser command was cancelled"), "cancelled");
1333
+ };
1334
+ options.signal?.addEventListener("abort", onAbort, { once: true });
1335
+ if (isCancelled()) onAbort();
1336
+ const timeout = setTimeout(() => {
1337
+ requestStop(new Error(`agent-browser timed out after ${options.timeoutMs}ms`), "timeout");
1338
+ }, options.timeoutMs);
1339
+ const stdout = readBoundedStream(
1340
+ child.stdout,
1341
+ options.maxOutputBytes,
1342
+ outputCancellation.signal,
1343
+ stdoutObservation,
1344
+ ).catch((error: unknown) => {
1345
+ stdoutObservation.failed = true;
1346
+ requestStop(error instanceof Error ? error : new Error(String(error)), "stdout-error");
1347
+ throw error;
1348
+ });
1349
+ const stderr = readBoundedStream(
1350
+ child.stderr,
1351
+ Math.min(options.maxOutputBytes, 2 * 1024 * 1024),
1352
+ outputCancellation.signal,
1353
+ stderrObservation,
1354
+ ).catch((error: unknown) => {
1355
+ stderrObservation.failed = true;
1356
+ requestStop(error instanceof Error ? error : new Error(String(error)), "stderr-error");
1357
+ throw error;
1358
+ });
1359
+ const completed = Promise.all([stdout, stderr, exited]);
1360
+ try {
1361
+ const [stdoutText, stderrText, exitCode] = await Promise.race([
1362
+ completed,
1363
+ stopped,
1364
+ ]);
1365
+ if (state.failure !== null) throw state.failure;
1366
+ return { stdout: stdoutText, stderr: stderrText, exitCode };
1367
+ } catch (error) {
1368
+ requestStop(error instanceof Error ? error : new Error(String(error)));
1369
+ let terminationFailure: unknown;
1370
+ let originalGroupTerminationSucceeded = false;
1371
+ try {
1372
+ await state.termination;
1373
+ originalGroupTerminationSucceeded = true;
1374
+ } catch (terminationError) {
1375
+ terminationFailure = terminationError;
1376
+ }
1377
+ if (terminationFailure !== undefined) child.unref();
1378
+ const resourcesSettled = await promiseSettlesWithin(
1379
+ Promise.allSettled([stdout, stderr, exited]),
1380
+ BROWSER_COMMAND_RESOURCE_SETTLEMENT_TIMEOUT_MS,
1381
+ );
1382
+ if (!resourcesSettled) child.unref();
1383
+ if (terminationFailure !== undefined) {
1384
+ throw diagnosed(terminationFailure instanceof BrowserCommandCleanupError
1385
+ ? terminationFailure
1386
+ : new BrowserCommandCleanupError(
1387
+ "agent-browser termination failed",
1388
+ terminationFailure,
1389
+ ), originalGroupTerminationSucceeded, resourcesSettled);
1390
+ }
1391
+ if (!resourcesSettled) {
1392
+ throw diagnosed(new BrowserCommandCleanupError(
1393
+ "agent-browser resources did not settle after process termination",
1394
+ ), originalGroupTerminationSucceeded, resourcesSettled);
1395
+ }
1396
+ const failure = state.failure ?? error;
1397
+ const normalizedFailure = failure instanceof Error
1398
+ ? failure
1399
+ : new Error(String(failure));
1400
+ if (normalizedFailure instanceof BrowserCommandCleanupError) {
1401
+ throw diagnosed(normalizedFailure, originalGroupTerminationSucceeded, resourcesSettled);
1402
+ }
1403
+ // A POSIX process group cannot prove that a descendant did not call
1404
+ // setsid(2) and become reparented before termination began. Any command
1405
+ // that required forced stopping therefore has an unprovable containment
1406
+ // boundary even when its original process group and streams are gone.
1407
+ throw diagnosed(new BrowserCommandCleanupError(
1408
+ `${normalizedFailure.message}; descendant process cleanup could not be verified`,
1409
+ normalizedFailure,
1410
+ ), originalGroupTerminationSucceeded, resourcesSettled);
1411
+ } finally {
1412
+ clearTimeout(timeout);
1413
+ options.signal?.removeEventListener("abort", onAbort);
1414
+ }
1415
+ }
1416
+
1417
+ export function parseLastJson(output: string): unknown {
1418
+ let lineEnd = output.length;
1419
+ while (lineEnd >= 0) {
1420
+ const newline = output.lastIndexOf("\n", lineEnd - 1);
1421
+ const line = output.slice(newline + 1, lineEnd).trim();
1422
+ lineEnd = newline;
1423
+ if (line.startsWith("[") || line.startsWith("{")) {
1424
+ try {
1425
+ return JSON.parse(line) as unknown;
1426
+ } catch {
1427
+ // Continue past diagnostics emitted before the JSON result.
1428
+ }
1429
+ }
1430
+ }
1431
+ throw new Error("agent-browser did not return JSON");
1432
+ }
1433
+
1434
+ const maximumAgentBrowserLaunchHash = (1n << 64n) - 1n;
1435
+ const maximumAgentBrowserJsonNesting = 256;
1436
+
1437
+ class AgentBrowserLaunchHashParseError extends Error {}
1438
+
1439
+ type JsonTextReplacement = {
1440
+ readonly start: number;
1441
+ readonly end: number;
1442
+ readonly value: string;
1443
+ };
1444
+
1445
+ /**
1446
+ * Rewrites only object values whose decoded key is exactly `launchHash`.
1447
+ * The surrounding JSON is parsed normally after every u64 token has become
1448
+ * a quoted canonical decimal string, avoiding JavaScript Number rounding.
1449
+ */
1450
+ class AgentBrowserLaunchHashRewriter {
1451
+ readonly #input: string;
1452
+ readonly #replacements: JsonTextReplacement[] = [];
1453
+ #index = 0;
1454
+
1455
+ constructor(input: string) {
1456
+ this.#input = input;
1457
+ }
1458
+
1459
+ rewrite(): string {
1460
+ this.#parseValue(false, 0);
1461
+ this.#skipWhitespace();
1462
+ if (this.#index !== this.#input.length) {
1463
+ throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
1464
+ }
1465
+ let rewritten = this.#input;
1466
+ for (const replacement of this.#replacements.toReversed()) {
1467
+ rewritten = `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`;
1468
+ }
1469
+ return rewritten;
1470
+ }
1471
+
1472
+ #parseValue(launchHash: boolean, depth: number): void {
1473
+ this.#skipWhitespace();
1474
+ if (launchHash) {
1475
+ if (this.#input.startsWith("null", this.#index)) {
1476
+ this.#parseLiteral("null");
1477
+ return;
1478
+ }
1479
+ this.#parseLaunchHash();
1480
+ return;
1481
+ }
1482
+ const token = this.#input[this.#index];
1483
+ if (token === "{") {
1484
+ this.#parseObject(depth + 1);
1485
+ return;
1486
+ }
1487
+ if (token === "[") {
1488
+ this.#parseArray(depth + 1);
1489
+ return;
1490
+ }
1491
+ if (token === '"') {
1492
+ this.#parseString();
1493
+ return;
1494
+ }
1495
+ if (token === "t") {
1496
+ this.#parseLiteral("true");
1497
+ return;
1498
+ }
1499
+ if (token === "f") {
1500
+ this.#parseLiteral("false");
1501
+ return;
1502
+ }
1503
+ if (token === "n") {
1504
+ this.#parseLiteral("null");
1505
+ return;
1506
+ }
1507
+ this.#parseNumber();
1508
+ }
1509
+
1510
+ #parseObject(depth: number): void {
1511
+ this.#assertNesting(depth);
1512
+ let sawLaunchHash = false;
1513
+ this.#index += 1;
1514
+ this.#skipWhitespace();
1515
+ if (this.#input[this.#index] === "}") {
1516
+ this.#index += 1;
1517
+ return;
1518
+ }
1519
+ for (;;) {
1520
+ this.#skipWhitespace();
1521
+ const key = this.#parseString();
1522
+ if (key === "launchHash") {
1523
+ if (sawLaunchHash) {
1524
+ throw new AgentBrowserLaunchHashParseError(
1525
+ "agent-browser JSON contains a duplicate launchHash field",
1526
+ );
1527
+ }
1528
+ sawLaunchHash = true;
1529
+ }
1530
+ this.#skipWhitespace();
1531
+ if (this.#input[this.#index] !== ":") {
1532
+ throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
1533
+ }
1534
+ this.#index += 1;
1535
+ this.#parseValue(key === "launchHash", depth);
1536
+ this.#skipWhitespace();
1537
+ const separator = this.#input[this.#index];
1538
+ if (separator === "}") {
1539
+ this.#index += 1;
1540
+ return;
1541
+ }
1542
+ if (separator !== ",") {
1543
+ throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
1544
+ }
1545
+ this.#index += 1;
1546
+ }
1547
+ }
1548
+
1549
+ #parseArray(depth: number): void {
1550
+ this.#assertNesting(depth);
1551
+ this.#index += 1;
1552
+ this.#skipWhitespace();
1553
+ if (this.#input[this.#index] === "]") {
1554
+ this.#index += 1;
1555
+ return;
1556
+ }
1557
+ for (;;) {
1558
+ this.#parseValue(false, depth);
1559
+ this.#skipWhitespace();
1560
+ const separator = this.#input[this.#index];
1561
+ if (separator === "]") {
1562
+ this.#index += 1;
1563
+ return;
1564
+ }
1565
+ if (separator !== ",") {
1566
+ throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
1567
+ }
1568
+ this.#index += 1;
1569
+ }
1570
+ }
1571
+
1572
+ #parseString(): string {
1573
+ const start = this.#index;
1574
+ if (this.#input[this.#index] !== '"') {
1575
+ throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
1576
+ }
1577
+ this.#index += 1;
1578
+ for (;;) {
1579
+ const token = this.#input[this.#index];
1580
+ if (token === undefined) {
1581
+ throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
1582
+ }
1583
+ if (token === '"') {
1584
+ this.#index += 1;
1585
+ return JSON.parse(this.#input.slice(start, this.#index)) as string;
1586
+ }
1587
+ if (token === "\\") {
1588
+ this.#index += 2;
1589
+ } else {
1590
+ this.#index += 1;
1591
+ }
1592
+ }
1593
+ }
1594
+
1595
+ #parseLiteral(literal: "true" | "false" | "null"): void {
1596
+ if (!this.#input.startsWith(literal, this.#index)) {
1597
+ throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
1598
+ }
1599
+ this.#index += literal.length;
1600
+ }
1601
+
1602
+ #parseNumber(): void {
1603
+ const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/u.exec(
1604
+ this.#input.slice(this.#index),
1605
+ );
1606
+ if (match === null) {
1607
+ throw new AgentBrowserLaunchHashParseError("agent-browser JSON is malformed");
1608
+ }
1609
+ this.#index += match[0].length;
1610
+ }
1611
+
1612
+ #parseLaunchHash(): void {
1613
+ const start = this.#index;
1614
+ const match = /^(?:0|[1-9][0-9]*)/u.exec(this.#input.slice(start));
1615
+ if (match === null) {
1616
+ throw new AgentBrowserLaunchHashParseError(
1617
+ "agent-browser launchHash is not an unsigned 64-bit integer",
1618
+ );
1619
+ }
1620
+ const value = match[0];
1621
+ this.#index += value.length;
1622
+ const following = this.#input[this.#index];
1623
+ if (
1624
+ following !== undefined
1625
+ && following !== ","
1626
+ && following !== "}"
1627
+ && following !== "]"
1628
+ && following !== " "
1629
+ && following !== "\t"
1630
+ && following !== "\r"
1631
+ && following !== "\n"
1632
+ ) {
1633
+ throw new AgentBrowserLaunchHashParseError(
1634
+ "agent-browser launchHash is not an unsigned 64-bit integer",
1635
+ );
1636
+ }
1637
+ if (BigInt(value) > maximumAgentBrowserLaunchHash) {
1638
+ throw new AgentBrowserLaunchHashParseError(
1639
+ "agent-browser launchHash exceeds an unsigned 64-bit integer",
1640
+ );
1641
+ }
1642
+ this.#replacements.push({
1643
+ start,
1644
+ end: this.#index,
1645
+ value: JSON.stringify(value),
1646
+ });
1647
+ }
1648
+
1649
+ #skipWhitespace(): void {
1650
+ while (
1651
+ this.#input[this.#index] === " "
1652
+ || this.#input[this.#index] === "\t"
1653
+ || this.#input[this.#index] === "\r"
1654
+ || this.#input[this.#index] === "\n"
1655
+ ) this.#index += 1;
1656
+ }
1657
+
1658
+ #assertNesting(depth: number): void {
1659
+ if (depth > maximumAgentBrowserJsonNesting) {
1660
+ throw new AgentBrowserLaunchHashParseError(
1661
+ "agent-browser JSON exceeds its nesting bound",
1662
+ );
1663
+ }
1664
+ }
1665
+ }
1666
+
1667
+ /**
1668
+ * Parse the last valid JSON output line without rounding agent-browser's u64
1669
+ * launch identities. Every object field decoded as `launchHash` is returned
1670
+ * as its exact canonical decimal string, while a disconnected browser's
1671
+ * `launchHash: null` remains null; every other JSON value is unchanged.
1672
+ */
1673
+ export function parseLastJsonWithExactLaunchHashes(output: string): unknown {
1674
+ let lineEnd = output.length;
1675
+ while (lineEnd >= 0) {
1676
+ const newline = output.lastIndexOf("\n", lineEnd - 1);
1677
+ const line = output.slice(newline + 1, lineEnd).trim();
1678
+ lineEnd = newline;
1679
+ if (line.startsWith("[") || line.startsWith("{")) {
1680
+ try {
1681
+ JSON.parse(line);
1682
+ } catch {
1683
+ // Continue past diagnostics emitted before the JSON result.
1684
+ continue;
1685
+ }
1686
+ const rewritten = new AgentBrowserLaunchHashRewriter(line).rewrite();
1687
+ return JSON.parse(rewritten) as unknown;
1688
+ }
1689
+ }
1690
+ throw new Error("agent-browser did not return JSON");
1691
+ }
1692
+
1693
+ type AgentBrowserSessionStateBase = {
1694
+ readonly state: "active";
1695
+ readonly pid: number;
1696
+ };
1697
+
1698
+ type LaunchedAgentBrowserSessionState = AgentBrowserSessionStateBase & {
1699
+ readonly browserLaunched: true;
1700
+ readonly engine: "chrome";
1701
+ readonly launchHash: string;
1702
+ };
1703
+
1704
+ type ClosedAgentBrowserDaemonState = AgentBrowserSessionStateBase & {
1705
+ readonly browserLaunched: false;
1706
+ readonly engine: "chrome";
1707
+ readonly launchHash: null;
1708
+ };
1709
+
1710
+ type ActiveAgentBrowserSessionState =
1711
+ | LaunchedAgentBrowserSessionState
1712
+ | ClosedAgentBrowserDaemonState;
1713
+
1714
+ type InactiveAgentBrowserSessionState = {
1715
+ readonly state: "inactive";
1716
+ };
1717
+
1718
+ type AgentBrowserSessionState =
1719
+ | ActiveAgentBrowserSessionState
1720
+ | InactiveAgentBrowserSessionState;
1721
+
1722
+ type AgentBrowserLifecycleRunCommand = (
1723
+ command: readonly string[],
1724
+ options: {
1725
+ readonly cwd: string;
1726
+ readonly environment: Readonly<Record<string, string>>;
1727
+ readonly timeoutMs: number;
1728
+ readonly maxOutputBytes: number;
1729
+ readonly signal?: AbortSignal;
1730
+ },
1731
+ ) => Promise<CommandResult>;
1732
+
1733
+ export type AgentBrowserLifecycleDependencies = {
1734
+ readonly runCommand?: AgentBrowserLifecycleRunCommand;
1735
+ readonly captureOwner?: (pid: number) => ProcessOwnerIdentity;
1736
+ readonly ownerStatus?: (owner: ProcessOwnerIdentity) => ProcessOwnerStatus;
1737
+ readonly terminateOwner?: (owner: ProcessOwnerIdentity) => void;
1738
+ readonly cdpEndpointStatus?: (
1739
+ cdpUrl: string,
1740
+ ) => Promise<"available" | "unavailable" | "indeterminate">;
1741
+ readonly sleep?: (milliseconds: number) => Promise<void>;
1742
+ readonly now?: () => number;
1743
+ /** Active-session setup only: borrow the kernel-owned cancellation signal. */
1744
+ readonly commandSignal?: AbortSignal;
1745
+ /** Recomputed before each setup command so no subcommand resets the budget. */
1746
+ readonly commandTimeoutMs?: () => number;
1747
+ };
1748
+
1749
+ function boundedAgentBrowserText(value: unknown): boolean {
1750
+ return value === null
1751
+ || (
1752
+ typeof value === "string"
1753
+ && value.length <= 64 * 1024
1754
+ && !/[\0\r\n]/u.test(value)
1755
+ );
1756
+ }
1757
+
1758
+ function parseAgentBrowserEffectiveLaunch(
1759
+ value: unknown,
1760
+ label: string,
1761
+ ):
1762
+ | {
1763
+ readonly browserLaunched: true;
1764
+ readonly engine: "chrome";
1765
+ readonly launchHash: string;
1766
+ }
1767
+ | {
1768
+ readonly browserLaunched: false;
1769
+ readonly engine: "chrome";
1770
+ readonly launchHash: null;
1771
+ } {
1772
+ const launch = browserIdentityRecord(value, label);
1773
+ browserIdentityExactKeys(
1774
+ launch,
1775
+ ["browserLaunched", "engine", "launchHash"],
1776
+ label,
1777
+ );
1778
+ if (launch.engine !== "chrome") {
1779
+ throw new Error("agent-browser control identity changed");
1780
+ }
1781
+ if (launch.browserLaunched === false) {
1782
+ if (launch.launchHash !== null) {
1783
+ throw new Error("agent-browser control identity changed");
1784
+ }
1785
+ return Object.freeze({
1786
+ browserLaunched: false,
1787
+ engine: "chrome",
1788
+ launchHash: null,
1789
+ });
1790
+ }
1791
+ if (launch.browserLaunched !== true) {
1792
+ throw new Error("agent-browser control identity changed");
1793
+ }
1794
+ return Object.freeze({
1795
+ browserLaunched: true,
1796
+ engine: "chrome",
1797
+ launchHash: canonicalAgentBrowserLaunchHash(launch.launchHash),
1798
+ });
1799
+ }
1800
+
1801
+ function parseAgentBrowserLifecycle(
1802
+ value: unknown,
1803
+ requireNonMutatingCdpSemantics = false,
1804
+ ): ReturnType<typeof parseAgentBrowserEffectiveLaunch> {
1805
+ const lifecycle = browserIdentityRecord(
1806
+ value,
1807
+ "agent-browser lifecycle",
1808
+ );
1809
+ browserIdentityExactKeys(lifecycle, [
1810
+ "effectiveLaunch",
1811
+ "launched",
1812
+ "relaunchedBrowser",
1813
+ "restartedBackground",
1814
+ "restoreStatus",
1815
+ "reused",
1816
+ "saveStatus",
1817
+ ], "agent-browser lifecycle");
1818
+ if (
1819
+ typeof lifecycle.launched !== "boolean"
1820
+ || typeof lifecycle.relaunchedBrowser !== "boolean"
1821
+ || typeof lifecycle.restartedBackground !== "boolean"
1822
+ || typeof lifecycle.reused !== "boolean"
1823
+ || typeof lifecycle.restoreStatus !== "string"
1824
+ || lifecycle.restoreStatus.length < 1
1825
+ || lifecycle.restoreStatus.length > 256
1826
+ || typeof lifecycle.saveStatus !== "string"
1827
+ || lifecycle.saveStatus.length < 1
1828
+ || lifecycle.saveStatus.length > 256
1829
+ || (
1830
+ requireNonMutatingCdpSemantics
1831
+ && (
1832
+ lifecycle.launched !== false
1833
+ || lifecycle.relaunchedBrowser !== false
1834
+ || lifecycle.restartedBackground !== false
1835
+ || lifecycle.restoreStatus !== "not_configured"
1836
+ || lifecycle.saveStatus !== "not_attempted"
1837
+ )
1838
+ )
1839
+ ) throw new Error("agent-browser lifecycle changed shape");
1840
+ return parseAgentBrowserEffectiveLaunch(
1841
+ lifecycle.effectiveLaunch,
1842
+ "agent-browser lifecycle launch",
1843
+ );
1844
+ }
1845
+
1846
+ function parseAgentBrowserSessionState(
1847
+ value: unknown,
1848
+ resource: BrowserCleanupResourceIdentity,
1849
+ ): AgentBrowserSessionState {
1850
+ const root = unwrapAgentBrowserIdentityResult(
1851
+ value,
1852
+ "agent-browser session result",
1853
+ );
1854
+ const data = browserIdentityRecord(root.data, "agent-browser session data");
1855
+ browserIdentityExactKeys(data, [
1856
+ "active",
1857
+ "namespace",
1858
+ "pid",
1859
+ "runtime",
1860
+ "runtimeError",
1861
+ "session",
1862
+ "socketDir",
1863
+ "version",
1864
+ ], "agent-browser session data");
1865
+ if (
1866
+ root.success !== true
1867
+ || data.namespace !== null
1868
+ || data.runtimeError !== null
1869
+ || data.session !== resource.session
1870
+ || data.socketDir !== resource.socketDirectory
1871
+ ) throw new Error("agent-browser session identity changed");
1872
+ if (data.active === false) {
1873
+ if (
1874
+ data.pid !== null
1875
+ || data.runtime !== null
1876
+ || data.version !== null
1877
+ ) throw new Error("agent-browser inactive session changed shape");
1878
+ return Object.freeze({ state: "inactive" });
1879
+ }
1880
+ if (
1881
+ data.active !== true
1882
+ || typeof data.pid !== "number"
1883
+ || !Number.isSafeInteger(data.pid)
1884
+ || data.pid < 1
1885
+ || data.version !== "0.32.3"
1886
+ ) throw new Error("agent-browser active session changed shape");
1887
+ const runtime = browserIdentityRecord(
1888
+ data.runtime,
1889
+ "agent-browser session runtime",
1890
+ );
1891
+ browserIdentityExactKeys(runtime, [
1892
+ "backgroundPid",
1893
+ "browserLaunched",
1894
+ "compatibilityStatus",
1895
+ "effectiveLaunch",
1896
+ "engine",
1897
+ "launchHash",
1898
+ "lifecycle",
1899
+ "namespace",
1900
+ "pageCount",
1901
+ "restoreCheckFn",
1902
+ "restoreCheckText",
1903
+ "restoreCheckUrl",
1904
+ "restoreKey",
1905
+ "restoreLoadedPath",
1906
+ "restoreSave",
1907
+ "restoreSavedPath",
1908
+ "restoreStatus",
1909
+ "restoreStatusDetail",
1910
+ "restoreValidationPending",
1911
+ "saveStatus",
1912
+ "session",
1913
+ "socketDir",
1914
+ ], "agent-browser session runtime");
1915
+ const launch = parseAgentBrowserEffectiveLaunch(
1916
+ runtime.effectiveLaunch,
1917
+ "agent-browser session effective launch",
1918
+ );
1919
+ const lifecycle = parseAgentBrowserLifecycle(runtime.lifecycle, true);
1920
+ const browserLaunched = launch.browserLaunched;
1921
+ if (
1922
+ runtime.backgroundPid !== data.pid
1923
+ || runtime.browserLaunched !== browserLaunched
1924
+ || runtime.compatibilityStatus !== "current"
1925
+ || runtime.engine !== "chrome"
1926
+ || runtime.launchHash !== launch.launchHash
1927
+ || runtime.namespace !== null
1928
+ || typeof runtime.pageCount !== "number"
1929
+ || !Number.isSafeInteger(runtime.pageCount)
1930
+ || runtime.pageCount < 0
1931
+ || runtime.pageCount > 100
1932
+ || !boundedAgentBrowserText(runtime.restoreCheckFn)
1933
+ || !boundedAgentBrowserText(runtime.restoreCheckText)
1934
+ || !boundedAgentBrowserText(runtime.restoreCheckUrl)
1935
+ || !boundedAgentBrowserText(runtime.restoreKey)
1936
+ || !boundedAgentBrowserText(runtime.restoreLoadedPath)
1937
+ || !["auto", "always", "never"].includes(
1938
+ typeof runtime.restoreSave === "string" ? runtime.restoreSave : "",
1939
+ )
1940
+ || !boundedAgentBrowserText(runtime.restoreSavedPath)
1941
+ || typeof runtime.restoreStatus !== "string"
1942
+ || runtime.restoreStatus.length < 1
1943
+ || runtime.restoreStatus.length > 256
1944
+ || !boundedAgentBrowserText(runtime.restoreStatusDetail)
1945
+ || typeof runtime.restoreValidationPending !== "boolean"
1946
+ || typeof runtime.saveStatus !== "string"
1947
+ || runtime.saveStatus.length < 1
1948
+ || runtime.saveStatus.length > 256
1949
+ || runtime.session !== resource.session
1950
+ || runtime.socketDir !== resource.socketDirectory
1951
+ || lifecycle.browserLaunched !== browserLaunched
1952
+ || lifecycle.engine !== launch.engine
1953
+ || lifecycle.launchHash !== launch.launchHash
1954
+ || (!browserLaunched && runtime.pageCount !== 0)
1955
+ ) throw new Error("agent-browser active session identity changed");
1956
+ if (launch.browserLaunched) {
1957
+ return Object.freeze({
1958
+ state: "active" as const,
1959
+ pid: data.pid,
1960
+ browserLaunched: true as const,
1961
+ engine: "chrome" as const,
1962
+ launchHash: launch.launchHash,
1963
+ });
1964
+ }
1965
+ return Object.freeze({
1966
+ state: "active" as const,
1967
+ pid: data.pid,
1968
+ browserLaunched: false as const,
1969
+ engine: "chrome" as const,
1970
+ launchHash: null,
1971
+ });
1972
+ }
1973
+
1974
+ function parseAgentBrowserCdpControl(
1975
+ value: unknown,
1976
+ ): {
1977
+ readonly cdpUrl: string;
1978
+ readonly engine: "chrome";
1979
+ readonly launchHash: string;
1980
+ } {
1981
+ const root = unwrapAgentBrowserIdentityResult(
1982
+ value,
1983
+ "agent-browser CDP result",
1984
+ );
1985
+ const data = browserIdentityRecord(root.data, "agent-browser CDP data");
1986
+ browserIdentityExactKeys(data, ["cdpUrl", "lifecycle"], "agent-browser CDP data");
1987
+ if (root.success !== true || typeof data.cdpUrl !== "string") {
1988
+ throw new Error("agent-browser CDP identity changed");
1989
+ }
1990
+ const lifecycle = parseAgentBrowserLifecycle(data.lifecycle, true);
1991
+ if (!lifecycle.browserLaunched) {
1992
+ throw new Error("agent-browser CDP identity changed");
1993
+ }
1994
+ return Object.freeze({
1995
+ cdpUrl: literalLoopbackAgentBrowserCdpUrl(data.cdpUrl),
1996
+ engine: lifecycle.engine,
1997
+ launchHash: lifecycle.launchHash,
1998
+ });
1999
+ }
2000
+
2001
+ function browserLifecycleCommandContext(
2002
+ resource: BrowserCleanupResourceIdentity,
2003
+ dependencies: AgentBrowserLifecycleDependencies,
2004
+ ): {
2005
+ readonly inspectSession: () => Promise<unknown>;
2006
+ readonly inspectCdp: () => Promise<unknown>;
2007
+ readonly close: () => Promise<boolean>;
2008
+ } {
2009
+ const recovery = parseBrowserRecoveryHandle(resource.recoveryHandle);
2010
+ const runner = dependencies.runCommand ?? runCommand;
2011
+ const environment = isolatedEnvironment(resource.socketDirectory);
2012
+ const common = [
2013
+ ...agentBrowserCommand(),
2014
+ "--config",
2015
+ recovery.configPath,
2016
+ "--session",
2017
+ resource.session,
2018
+ "--content-boundaries",
2019
+ "--max-output",
2020
+ "1048576",
2021
+ ] as const;
2022
+ const invoke = async (
2023
+ suffix: readonly string[],
2024
+ label: string,
2025
+ ): Promise<CommandResult> => {
2026
+ try {
2027
+ return await runner([...common, ...suffix], {
2028
+ cwd: resource.artifactsDirectory,
2029
+ environment,
2030
+ timeoutMs: dependencies.commandTimeoutMs?.() ?? 10_000,
2031
+ maxOutputBytes: 1024 * 1024,
2032
+ ...(dependencies.commandSignal === undefined
2033
+ ? {}
2034
+ : { signal: dependencies.commandSignal }),
2035
+ });
2036
+ } catch (error) {
2037
+ if (error instanceof BrowserCommandCleanupError) throw error;
2038
+ throw new AgentBrowserLifecycleCommandUnavailableError(label);
2039
+ }
2040
+ };
2041
+ return Object.freeze({
2042
+ inspectSession: async () => {
2043
+ const result = await invoke(
2044
+ ["session", "info", "--json"],
2045
+ "session inspection",
2046
+ );
2047
+ if (result.exitCode !== 0) {
2048
+ throw new AgentBrowserLifecycleCommandUnavailableError(
2049
+ "session inspection",
2050
+ );
2051
+ }
2052
+ try {
2053
+ return parseLastJsonWithExactLaunchHashes(result.stdout);
2054
+ } catch {
2055
+ throw new Error("agent-browser session inspection changed shape");
2056
+ }
2057
+ },
2058
+ inspectCdp: async () => {
2059
+ const result = await invoke(
2060
+ [
2061
+ "--action-policy",
2062
+ join(resource.artifactsDirectory, "action-policy.json"),
2063
+ "--json",
2064
+ "get",
2065
+ "cdp-url",
2066
+ ],
2067
+ "control inspection",
2068
+ );
2069
+ if (result.exitCode !== 0) {
2070
+ throw new AgentBrowserLifecycleCommandUnavailableError(
2071
+ "control inspection",
2072
+ );
2073
+ }
2074
+ try {
2075
+ return parseLastJsonWithExactLaunchHashes(result.stdout);
2076
+ } catch {
2077
+ throw new Error("agent-browser control inspection changed shape");
2078
+ }
2079
+ },
2080
+ close: async () => {
2081
+ const result = await invoke(["close", "--json"], "graceful close");
2082
+ return result.exitCode === 0;
2083
+ },
2084
+ });
2085
+ }
2086
+
2087
+ /**
2088
+ * Prove that prepared roots never crossed the durable launch-intent boundary.
2089
+ * Two exact inactive-session reads bracket an unchanged-root observation.
2090
+ */
2091
+ export async function provePreparedAgentBrowserCleanupResourceQuiescent(
2092
+ value: BrowserCleanupResourceIdentityV2,
2093
+ dependencies: AgentBrowserLifecycleDependencies = {},
2094
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2095
+ const resource = parseBrowserCleanupResourceIdentity(value);
2096
+ if (
2097
+ resource.kind !== "agent-browser-session-v2"
2098
+ || resource.phase !== "prepared"
2099
+ ) {
2100
+ throw new Error("browser cleanup resource is not prepared");
2101
+ }
2102
+ const lifecycle = browserLifecycleCommandContext(resource, dependencies);
2103
+ assertBrowserCleanupResourceRootsMatch(resource);
2104
+ const first = parseAgentBrowserSessionState(
2105
+ await lifecycle.inspectSession(),
2106
+ resource,
2107
+ );
2108
+ if (first.state !== "inactive") {
2109
+ throw new Error("prepared browser cleanup session became active");
2110
+ }
2111
+ assertBrowserCleanupResourceRootsMatch(resource);
2112
+ const second = parseAgentBrowserSessionState(
2113
+ await lifecycle.inspectSession(),
2114
+ resource,
2115
+ );
2116
+ if (second.state !== "inactive") {
2117
+ throw new Error("prepared browser cleanup session state changed");
2118
+ }
2119
+ assertBrowserCleanupResourceRootsMatch(resource);
2120
+ return resource;
2121
+ }
2122
+
2123
+ function exactActiveAgentBrowserControl(
2124
+ state: AgentBrowserSessionState,
2125
+ control: AgentBrowserControlWitnessV1,
2126
+ ): LaunchedAgentBrowserSessionState {
2127
+ if (
2128
+ state.state !== "active"
2129
+ || !state.browserLaunched
2130
+ || state.pid !== control.daemonOwner.pid
2131
+ || state.engine !== control.engine
2132
+ || state.launchHash !== control.launchHash
2133
+ ) throw new Error("agent-browser control identity changed");
2134
+ return state;
2135
+ }
2136
+
2137
+ function exactClosedAgentBrowserDaemon(
2138
+ state: AgentBrowserSessionState,
2139
+ control: AgentBrowserControlWitnessV1,
2140
+ ): ClosedAgentBrowserDaemonState {
2141
+ if (
2142
+ state.state !== "active"
2143
+ || state.browserLaunched
2144
+ || state.pid !== control.daemonOwner.pid
2145
+ || state.engine !== control.engine
2146
+ || state.launchHash !== null
2147
+ ) throw new Error("agent-browser control identity changed");
2148
+ return state;
2149
+ }
2150
+
2151
+ function exactPinnedAgentBrowserCdpControl(
2152
+ value: unknown,
2153
+ control: AgentBrowserControlWitnessV1,
2154
+ ): void {
2155
+ const current = parseAgentBrowserCdpControl(value);
2156
+ if (
2157
+ current.cdpUrl !== control.cdpUrl
2158
+ || current.engine !== control.engine
2159
+ || current.launchHash !== control.launchHash
2160
+ ) throw new Error("agent-browser CDP control identity changed");
2161
+ }
2162
+
2163
+ function promoteLegacyBrowserCleanupResource(
2164
+ value: BrowserCleanupResourceIdentityV1,
2165
+ ): BrowserCleanupResourceIdentityV2 {
2166
+ const legacy = parseBrowserCleanupResourceIdentity(value);
2167
+ if (legacy.kind !== "agent-browser-session-v1") {
2168
+ throw new Error("legacy browser cleanup resource is malformed");
2169
+ }
2170
+ const promoted = parseBrowserCleanupResourceIdentity({
2171
+ ...legacy,
2172
+ kind: "agent-browser-session-v2",
2173
+ socketDirectoryIdentity: privateBrowserDirectoryIdentityV2(
2174
+ legacy.socketDirectory,
2175
+ ),
2176
+ artifactsDirectoryIdentity: privateBrowserDirectoryIdentityV2(
2177
+ legacy.artifactsDirectory,
2178
+ ),
2179
+ phase: "launch-intent",
2180
+ control: null,
2181
+ });
2182
+ if (
2183
+ promoted.kind !== "agent-browser-session-v2"
2184
+ || legacy.socketDirectoryIdentity.device
2185
+ !== promoted.socketDirectoryIdentity.device
2186
+ || legacy.socketDirectoryIdentity.inode
2187
+ !== promoted.socketDirectoryIdentity.inode
2188
+ || legacy.artifactsDirectoryIdentity.device
2189
+ !== promoted.artifactsDirectoryIdentity.device
2190
+ || legacy.artifactsDirectoryIdentity.inode
2191
+ !== promoted.artifactsDirectoryIdentity.inode
2192
+ ) throw new Error("legacy browser cleanup private root identity changed");
2193
+ return promoted;
2194
+ }
2195
+
2196
+ /**
2197
+ * Bind one running private agent-browser session to a durable daemon-start,
2198
+ * browser-launch, and CDP witness. Three stable session reads bracket two
2199
+ * identical CDP reads and process-owner capture; any drift leaves the prior
2200
+ * launch-intent identity intact.
2201
+ */
2202
+ export async function bindLiveAgentBrowserCleanupResource(
2203
+ value: BrowserCleanupResourceIdentityV2,
2204
+ dependencies: AgentBrowserLifecycleDependencies = {},
2205
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2206
+ const resource = parseBrowserCleanupResourceIdentity(value);
2207
+ if (
2208
+ resource.kind !== "agent-browser-session-v2"
2209
+ || resource.phase !== "launch-intent"
2210
+ ) {
2211
+ throw new Error("browser cleanup resource cannot accept a control witness");
2212
+ }
2213
+ assertBrowserCleanupResourceRootsMatch(resource);
2214
+ const lifecycle = browserLifecycleCommandContext(resource, dependencies);
2215
+ const first = parseAgentBrowserSessionState(
2216
+ await lifecycle.inspectSession(),
2217
+ resource,
2218
+ );
2219
+ if (first.state !== "active" || !first.browserLaunched) {
2220
+ throw new AgentBrowserLiveControlUnavailableError();
2221
+ }
2222
+ const captureOwner = dependencies.captureOwner
2223
+ ?? captureProcessOwnerIdentity;
2224
+ let owner: ProcessOwnerIdentity;
2225
+ try {
2226
+ owner = captureOwner(first.pid);
2227
+ } catch {
2228
+ throw new Error("agent-browser live control identity is unavailable");
2229
+ }
2230
+ const cdp = parseAgentBrowserCdpControl(await lifecycle.inspectCdp());
2231
+ const second = parseAgentBrowserSessionState(
2232
+ await lifecycle.inspectSession(),
2233
+ resource,
2234
+ );
2235
+ const repeatedCdp = parseAgentBrowserCdpControl(
2236
+ await lifecycle.inspectCdp(),
2237
+ );
2238
+ const third = parseAgentBrowserSessionState(
2239
+ await lifecycle.inspectSession(),
2240
+ resource,
2241
+ );
2242
+ const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
2243
+ if (
2244
+ second.state !== "active"
2245
+ || !second.browserLaunched
2246
+ || second.pid !== first.pid
2247
+ || second.engine !== first.engine
2248
+ || second.launchHash !== first.launchHash
2249
+ || cdp.engine !== first.engine
2250
+ || cdp.launchHash !== first.launchHash
2251
+ || repeatedCdp.cdpUrl !== cdp.cdpUrl
2252
+ || repeatedCdp.engine !== cdp.engine
2253
+ || repeatedCdp.launchHash !== cdp.launchHash
2254
+ || third.state !== "active"
2255
+ || !third.browserLaunched
2256
+ || third.pid !== first.pid
2257
+ || third.engine !== first.engine
2258
+ || third.launchHash !== first.launchHash
2259
+ || owner.pid !== first.pid
2260
+ || inspectOwner(owner) !== "exact-live-owner"
2261
+ ) throw new Error("agent-browser control identity changed while it was bound");
2262
+ assertBrowserCleanupResourceRootsMatch(resource);
2263
+ const next = parseBrowserCleanupResourceIdentity({
2264
+ ...resource,
2265
+ phase: "controlled",
2266
+ control: {
2267
+ kind: "agent-browser-control-v1",
2268
+ version: "0.32.3",
2269
+ session: resource.session,
2270
+ socketDirectory: resource.socketDirectory,
2271
+ daemonOwner: owner,
2272
+ engine: "chrome",
2273
+ launchHash: first.launchHash,
2274
+ cdpUrl: cdp.cdpUrl,
2275
+ },
2276
+ });
2277
+ if (
2278
+ next.kind !== "agent-browser-session-v2"
2279
+ || !browserCleanupResourceExtends(resource, next)
2280
+ ) throw new Error("browser cleanup control identity is not monotonic");
2281
+ return next;
2282
+ }
2283
+
2284
+ /** Existing v1 claims may be upgraded only while their exact daemon is live. */
2285
+ export async function adoptLiveLegacyBrowserCleanupResource(
2286
+ value: BrowserCleanupResourceIdentityV1,
2287
+ dependencies: AgentBrowserLifecycleDependencies = {},
2288
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2289
+ const legacy = parseBrowserCleanupResourceIdentity(value);
2290
+ if (legacy.kind !== "agent-browser-session-v1") {
2291
+ throw new Error("legacy browser cleanup resource is malformed");
2292
+ }
2293
+ const promoted = promoteLegacyBrowserCleanupResource(legacy);
2294
+ const pinned = await bindLiveAgentBrowserCleanupResource(
2295
+ promoted,
2296
+ dependencies,
2297
+ );
2298
+ if (!browserCleanupResourceExtends(legacy, pinned)) {
2299
+ throw new Error("legacy browser cleanup resource could not be adopted");
2300
+ }
2301
+ return pinned;
2302
+ }
2303
+
2304
+ export async function exactAgentBrowserCdpEndpointStatus(
2305
+ cdpUrl: string,
2306
+ ): Promise<"available" | "unavailable" | "indeterminate"> {
2307
+ let endpoint: string;
2308
+ try {
2309
+ endpoint = literalLoopbackAgentBrowserCdpUrl(cdpUrl);
2310
+ } catch {
2311
+ return "indeterminate";
2312
+ }
2313
+ const url = new URL(endpoint);
2314
+ const port = Number(url.port);
2315
+ const host = url.hostname === "[::1]" ? "::1" : url.hostname;
2316
+ if (host !== "127.0.0.1" && host !== "::1") {
2317
+ return "indeterminate";
2318
+ }
2319
+ return new Promise((resolveStatus) => {
2320
+ const socket = createConnection({ host, port });
2321
+ let settled = false;
2322
+ const finish = (
2323
+ status: "available" | "unavailable" | "indeterminate",
2324
+ ): void => {
2325
+ if (settled) return;
2326
+ settled = true;
2327
+ clearTimeout(timer);
2328
+ socket.destroy();
2329
+ resolveStatus(status);
2330
+ };
2331
+ const timer = setTimeout(() => finish("indeterminate"), 250);
2332
+ socket.once("connect", () => finish("available"));
2333
+ socket.once("error", (error: NodeJS.ErrnoException) => {
2334
+ finish(error.code === "ECONNREFUSED" ? "unavailable" : "indeterminate");
2335
+ });
2336
+ socket.once("close", () => finish("indeterminate"));
2337
+ });
2338
+ }
2339
+
2340
+ /**
2341
+ * Prove a pinned browser already quiescent without closing or signaling it.
2342
+ * Two exact inactive-session observations and unchanged private roots precede
2343
+ * three endpoint refusals. A final dead-owner, inactive-session, and root
2344
+ * reproof closes the race at the return boundary.
2345
+ */
2346
+ async function provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
2347
+ value: BrowserCleanupResourceIdentityV2,
2348
+ dependencies: AgentBrowserLifecycleDependencies = {},
2349
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2350
+ const resource = parseBrowserCleanupResourceIdentity(value);
2351
+ if (
2352
+ resource.kind !== "agent-browser-session-v2"
2353
+ || resource.phase !== "controlled"
2354
+ ) {
2355
+ throw new Error("browser cleanup resource does not have an exact control witness");
2356
+ }
2357
+ const control = resource.control;
2358
+ const lifecycle = browserLifecycleCommandContext(resource, dependencies);
2359
+ const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
2360
+ const sleep = dependencies.sleep
2361
+ ?? ((milliseconds: number) => Bun.sleep(milliseconds));
2362
+ const endpointStatus = dependencies.cdpEndpointStatus
2363
+ ?? exactAgentBrowserCdpEndpointStatus;
2364
+ assertBrowserCleanupResourceRootsMatch(resource);
2365
+ let ownerStatus = inspectOwner(control.daemonOwner);
2366
+ if (ownerStatus === "unknown") {
2367
+ throw new Error("browser cleanup daemon state is indeterminate");
2368
+ }
2369
+ if (ownerStatus === "exact-live-owner") {
2370
+ let transitionUnavailable: AgentBrowserLifecycleCommandUnavailableError
2371
+ | null = null;
2372
+ try {
2373
+ const transition = parseAgentBrowserSessionState(
2374
+ await lifecycle.inspectSession(),
2375
+ resource,
2376
+ );
2377
+ if (transition.state === "active") {
2378
+ if (transition.browserLaunched) {
2379
+ exactActiveAgentBrowserControl(transition, control);
2380
+ } else {
2381
+ exactClosedAgentBrowserDaemon(transition, control);
2382
+ }
2383
+ }
2384
+ } catch (error) {
2385
+ if (!(error instanceof AgentBrowserLifecycleCommandUnavailableError)) {
2386
+ throw error;
2387
+ }
2388
+ transitionUnavailable = error;
2389
+ }
2390
+ ownerStatus = inspectOwner(control.daemonOwner);
2391
+ if (ownerStatus === "unknown") {
2392
+ throw new Error("browser cleanup daemon state is indeterminate");
2393
+ }
2394
+ if (ownerStatus === "exact-live-owner") {
2395
+ assertBrowserCleanupResourceRootsMatch(resource);
2396
+ if (transitionUnavailable !== null) throw transitionUnavailable;
2397
+ throw new AgentBrowserCleanupOwnerStillLiveError();
2398
+ }
2399
+ assertBrowserCleanupResourceRootsMatch(resource);
2400
+ }
2401
+ for (let read = 0; read < 2; read += 1) {
2402
+ const inactive = parseAgentBrowserSessionState(
2403
+ await lifecycle.inspectSession(),
2404
+ resource,
2405
+ );
2406
+ if (inactive.state !== "inactive") {
2407
+ throw new Error(read === 0
2408
+ ? "browser cleanup session remained active"
2409
+ : "browser cleanup session quiescence changed");
2410
+ }
2411
+ assertBrowserCleanupResourceRootsMatch(resource);
2412
+ }
2413
+ for (let refusal = 0; refusal < 3; refusal += 1) {
2414
+ const status = await endpointStatus(control.cdpUrl);
2415
+ if (status === "indeterminate") {
2416
+ throw new Error("browser cleanup endpoint state is indeterminate");
2417
+ }
2418
+ if (status !== "unavailable") {
2419
+ throw new Error("browser cleanup endpoint remained available");
2420
+ }
2421
+ assertBrowserCleanupResourceRootsMatch(resource);
2422
+ if (refusal < 2) await sleep(25);
2423
+ }
2424
+ if (inspectOwner(control.daemonOwner) !== "different-or-dead") {
2425
+ throw new Error("browser cleanup daemon quiescence changed");
2426
+ }
2427
+ const finalInactive = parseAgentBrowserSessionState(
2428
+ await lifecycle.inspectSession(),
2429
+ resource,
2430
+ );
2431
+ if (finalInactive.state !== "inactive") {
2432
+ throw new Error("browser cleanup session quiescence changed");
2433
+ }
2434
+ assertBrowserCleanupResourceRootsMatch(resource);
2435
+ return resource;
2436
+ }
2437
+
2438
+ type PinnedAgentBrowserCleanupRecoveryMode =
2439
+ | "may-close-browser"
2440
+ | "close-already-attempted";
2441
+
2442
+ /**
2443
+ * Prove one pinned daemon and browser endpoint quiescent within fixed bounds.
2444
+ * The only signal permitted for the exact pinned owner is SIGTERM. This
2445
+ * function never deletes roots; its caller must CAS the durable claim and
2446
+ * recheck the same identities immediately before deletion.
2447
+ */
2448
+ async function recoverPinnedAgentBrowserCleanupResourceCore(
2449
+ value: BrowserCleanupResourceIdentityV2,
2450
+ dependencies: AgentBrowserLifecycleDependencies,
2451
+ mode: PinnedAgentBrowserCleanupRecoveryMode,
2452
+ postCloseEffectDeadline: number | null = null,
2453
+ admitPostCloseTermination: (() => void) | null = null,
2454
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2455
+ const resource = parseBrowserCleanupResourceIdentity(value);
2456
+ if (
2457
+ resource.kind !== "agent-browser-session-v2"
2458
+ || resource.phase !== "controlled"
2459
+ ) {
2460
+ throw new Error("browser cleanup resource does not have an exact control witness");
2461
+ }
2462
+ const control = resource.control;
2463
+ const postClose = mode === "close-already-attempted";
2464
+ const lifecycle = browserLifecycleCommandContext(resource, dependencies);
2465
+ const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
2466
+ const sleep = dependencies.sleep ?? ((milliseconds: number) => Bun.sleep(milliseconds));
2467
+ const now = dependencies.now ?? Date.now;
2468
+ const endpointStatus = dependencies.cdpEndpointStatus
2469
+ ?? exactAgentBrowserCdpEndpointStatus;
2470
+ const terminateOwner = dependencies.terminateOwner
2471
+ ?? ((owner: ProcessOwnerIdentity): void => {
2472
+ process.kill(owner.pid, "SIGTERM");
2473
+ });
2474
+ const proveNaturalExitAtBoundary = async (
2475
+ boundaryFailure: unknown,
2476
+ ): Promise<BrowserCleanupResourceIdentityV2> => {
2477
+ const status = inspectOwner(control.daemonOwner);
2478
+ if (status === "unknown") {
2479
+ throw new Error("browser cleanup daemon state became indeterminate");
2480
+ }
2481
+ if (status === "exact-live-owner") {
2482
+ assertBrowserCleanupResourceRootsMatch(resource);
2483
+ if (
2484
+ postClose
2485
+ && boundaryFailure instanceof AgentBrowserLifecycleCommandUnavailableError
2486
+ ) {
2487
+ throw new AgentBrowserPostCloseTransitionStillSettlingError();
2488
+ }
2489
+ throw boundaryFailure;
2490
+ }
2491
+ return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
2492
+ resource,
2493
+ dependencies,
2494
+ );
2495
+ };
2496
+ const observeSessionAtBoundary = async (): Promise<
2497
+ | {
2498
+ readonly kind: "session";
2499
+ readonly value: AgentBrowserSessionState;
2500
+ }
2501
+ | {
2502
+ readonly kind: "quiescent";
2503
+ readonly resource: BrowserCleanupResourceIdentityV2;
2504
+ }
2505
+ > => {
2506
+ try {
2507
+ return Object.freeze({
2508
+ kind: "session" as const,
2509
+ value: parseAgentBrowserSessionState(
2510
+ await lifecycle.inspectSession(),
2511
+ resource,
2512
+ ),
2513
+ });
2514
+ } catch (error) {
2515
+ if (error instanceof AgentBrowserLifecycleCommandUnavailableError) {
2516
+ return Object.freeze({
2517
+ kind: "quiescent" as const,
2518
+ resource: await proveNaturalExitAtBoundary(error),
2519
+ });
2520
+ }
2521
+ throw error;
2522
+ }
2523
+ };
2524
+ const observePinnedCdpAtBoundary = async (): Promise<
2525
+ | { readonly kind: "control" }
2526
+ | {
2527
+ readonly kind: "quiescent";
2528
+ readonly resource: BrowserCleanupResourceIdentityV2;
2529
+ }
2530
+ > => {
2531
+ let observed: unknown;
2532
+ try {
2533
+ observed = await lifecycle.inspectCdp();
2534
+ } catch (error) {
2535
+ if (error instanceof AgentBrowserLifecycleCommandUnavailableError) {
2536
+ return Object.freeze({
2537
+ kind: "quiescent" as const,
2538
+ resource: await proveNaturalExitAtBoundary(error),
2539
+ });
2540
+ }
2541
+ throw error;
2542
+ }
2543
+ exactPinnedAgentBrowserCdpControl(observed, control);
2544
+ return Object.freeze({ kind: "control" as const });
2545
+ };
2546
+ assertBrowserCleanupResourceRootsMatch(resource);
2547
+ const initialOwnerStatus = inspectOwner(control.daemonOwner);
2548
+ if (initialOwnerStatus === "unknown") {
2549
+ throw new Error("browser cleanup daemon state is indeterminate");
2550
+ }
2551
+ if (initialOwnerStatus === "different-or-dead") {
2552
+ return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
2553
+ resource,
2554
+ dependencies,
2555
+ );
2556
+ }
2557
+ if (initialOwnerStatus === "exact-live-owner") {
2558
+ const initialObservation = await observeSessionAtBoundary();
2559
+ if (initialObservation.kind === "quiescent") {
2560
+ return initialObservation.resource;
2561
+ }
2562
+ const initialSession = initialObservation.value;
2563
+ if (initialSession.state === "inactive") {
2564
+ if (postClose) {
2565
+ const transitionOwnerStatus = inspectOwner(control.daemonOwner);
2566
+ if (transitionOwnerStatus === "unknown") {
2567
+ throw new Error("browser cleanup daemon state became indeterminate");
2568
+ }
2569
+ if (transitionOwnerStatus === "exact-live-owner") {
2570
+ assertBrowserCleanupResourceRootsMatch(resource);
2571
+ throw new AgentBrowserPostCloseTransitionStillSettlingError();
2572
+ }
2573
+ }
2574
+ return proveNaturalExitAtBoundary(
2575
+ new Error("browser cleanup daemon and session identity disagree"),
2576
+ );
2577
+ } else if (
2578
+ initialSession.browserLaunched
2579
+ && postClose
2580
+ ) {
2581
+ exactActiveAgentBrowserControl(initialSession, control);
2582
+ assertBrowserCleanupResourceRootsMatch(resource);
2583
+ throw new AgentBrowserPostCloseTransitionStillSettlingError();
2584
+ } else if (initialSession.browserLaunched) {
2585
+ exactActiveAgentBrowserControl(initialSession, control);
2586
+ const firstCdp = await observePinnedCdpAtBoundary();
2587
+ if (firstCdp.kind === "quiescent") return firstCdp.resource;
2588
+ const repeatedActiveObservation = await observeSessionAtBoundary();
2589
+ if (repeatedActiveObservation.kind === "quiescent") {
2590
+ return repeatedActiveObservation.resource;
2591
+ }
2592
+ const repeatedActive = repeatedActiveObservation.value;
2593
+ if (repeatedActive.state === "inactive") {
2594
+ return proveNaturalExitAtBoundary(
2595
+ new Error("browser cleanup daemon identity changed before close"),
2596
+ );
2597
+ }
2598
+ exactActiveAgentBrowserControl(repeatedActive, control);
2599
+ const beforeCloseStatus = inspectOwner(control.daemonOwner);
2600
+ if (beforeCloseStatus === "unknown") {
2601
+ throw new Error("browser cleanup daemon state became indeterminate");
2602
+ }
2603
+ if (beforeCloseStatus === "different-or-dead") {
2604
+ return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
2605
+ resource,
2606
+ dependencies,
2607
+ );
2608
+ }
2609
+ assertBrowserCleanupResourceRootsMatch(resource);
2610
+ let closeSucceeded = false;
2611
+ try {
2612
+ closeSucceeded = await lifecycle.close();
2613
+ } catch (error) {
2614
+ if (error instanceof AgentBrowserLifecycleCommandUnavailableError) {
2615
+ return proveNaturalExitAtBoundary(error);
2616
+ }
2617
+ throw error;
2618
+ }
2619
+ const afterCloseObservation = await observeSessionAtBoundary();
2620
+ if (afterCloseObservation.kind === "quiescent") {
2621
+ return afterCloseObservation.resource;
2622
+ }
2623
+ const afterClose = afterCloseObservation.value;
2624
+ if (afterClose.state === "active") {
2625
+ if (afterClose.browserLaunched) {
2626
+ exactActiveAgentBrowserControl(afterClose, control);
2627
+ } else {
2628
+ exactClosedAgentBrowserDaemon(afterClose, control);
2629
+ }
2630
+ }
2631
+ const afterCloseOwnerStatus = inspectOwner(control.daemonOwner);
2632
+ if (afterCloseOwnerStatus === "unknown") {
2633
+ throw new Error("browser cleanup daemon state became indeterminate");
2634
+ }
2635
+ if (afterCloseOwnerStatus === "different-or-dead") {
2636
+ return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
2637
+ resource,
2638
+ dependencies,
2639
+ );
2640
+ }
2641
+ if (afterCloseOwnerStatus === "exact-live-owner") {
2642
+ if (afterClose.state === "active" && afterClose.browserLaunched) {
2643
+ const afterCloseCdp = await observePinnedCdpAtBoundary();
2644
+ if (afterCloseCdp.kind === "quiescent") {
2645
+ return afterCloseCdp.resource;
2646
+ }
2647
+ const repeatedAfterCloseObservation = await observeSessionAtBoundary();
2648
+ if (repeatedAfterCloseObservation.kind === "quiescent") {
2649
+ return repeatedAfterCloseObservation.resource;
2650
+ }
2651
+ const repeatedAfterClose = repeatedAfterCloseObservation.value;
2652
+ if (repeatedAfterClose.state === "inactive") {
2653
+ return proveNaturalExitAtBoundary(
2654
+ new Error("browser cleanup control identity changed"),
2655
+ );
2656
+ }
2657
+ exactActiveAgentBrowserControl(repeatedAfterClose, control);
2658
+ } else if (afterClose.state === "active") {
2659
+ if (!closeSucceeded) {
2660
+ throw new Error("agent-browser graceful close could not be verified");
2661
+ }
2662
+ const repeatedClosedObservation = await observeSessionAtBoundary();
2663
+ if (repeatedClosedObservation.kind === "quiescent") {
2664
+ return repeatedClosedObservation.resource;
2665
+ }
2666
+ const repeatedClosed = repeatedClosedObservation.value;
2667
+ if (repeatedClosed.state === "inactive") {
2668
+ return proveNaturalExitAtBoundary(
2669
+ new Error("browser cleanup control identity changed"),
2670
+ );
2671
+ }
2672
+ exactClosedAgentBrowserDaemon(repeatedClosed, control);
2673
+ } else {
2674
+ const repeatedInactiveObservation = await observeSessionAtBoundary();
2675
+ if (repeatedInactiveObservation.kind === "quiescent") {
2676
+ return repeatedInactiveObservation.resource;
2677
+ }
2678
+ const repeatedInactive = repeatedInactiveObservation.value;
2679
+ if (repeatedInactive.state !== "inactive") {
2680
+ throw new Error("browser cleanup session state changed before termination");
2681
+ }
2682
+ }
2683
+ }
2684
+ } else {
2685
+ exactClosedAgentBrowserDaemon(initialSession, control);
2686
+ const repeatedClosedObservation = await observeSessionAtBoundary();
2687
+ if (repeatedClosedObservation.kind === "quiescent") {
2688
+ return repeatedClosedObservation.resource;
2689
+ }
2690
+ const repeatedClosed = repeatedClosedObservation.value;
2691
+ if (repeatedClosed.state === "inactive") {
2692
+ return proveNaturalExitAtBoundary(
2693
+ new Error("browser cleanup control identity changed"),
2694
+ );
2695
+ }
2696
+ exactClosedAgentBrowserDaemon(repeatedClosed, control);
2697
+ }
2698
+ if (initialSession.state === "active") {
2699
+ const beforeTermination = inspectOwner(control.daemonOwner);
2700
+ if (beforeTermination === "unknown") {
2701
+ throw new Error("browser cleanup daemon state became indeterminate");
2702
+ }
2703
+ if (beforeTermination === "exact-live-owner") {
2704
+ assertBrowserCleanupResourceRootsMatch(resource);
2705
+ if (
2706
+ postClose
2707
+ && postCloseEffectDeadline !== null
2708
+ && now() >= postCloseEffectDeadline
2709
+ ) {
2710
+ throw new Error(
2711
+ "browser cleanup post-close convergence deadline expired",
2712
+ );
2713
+ }
2714
+ admitPostCloseTermination?.();
2715
+ try {
2716
+ terminateOwner(control.daemonOwner);
2717
+ } catch {
2718
+ if (inspectOwner(control.daemonOwner) === "exact-live-owner") {
2719
+ throw new Error("browser cleanup daemon did not accept graceful termination");
2720
+ }
2721
+ }
2722
+ const ownerDeadline = postClose
2723
+ && postCloseEffectDeadline !== null
2724
+ ? Math.min(now() + 5_000, postCloseEffectDeadline)
2725
+ : now() + 5_000;
2726
+ let ownerSleeps = 0;
2727
+ for (;;) {
2728
+ const status = inspectOwner(control.daemonOwner);
2729
+ if (status === "unknown") {
2730
+ throw new Error("browser cleanup daemon state became indeterminate");
2731
+ }
2732
+ if (status === "different-or-dead") break;
2733
+ if (
2734
+ ownerSleeps >= (postClose ? 80 : 200)
2735
+ || now() >= ownerDeadline
2736
+ ) {
2737
+ throw new Error("browser cleanup daemon did not stop after SIGTERM");
2738
+ }
2739
+ ownerSleeps += 1;
2740
+ await sleep(25);
2741
+ }
2742
+ } else {
2743
+ return provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
2744
+ resource,
2745
+ dependencies,
2746
+ );
2747
+ }
2748
+ }
2749
+ }
2750
+ const finalOwnerStatus = inspectOwner(control.daemonOwner);
2751
+ if (finalOwnerStatus !== "different-or-dead") {
2752
+ throw new Error("browser cleanup daemon quiescence is unproved");
2753
+ }
2754
+ const inactive = parseAgentBrowserSessionState(
2755
+ await lifecycle.inspectSession(),
2756
+ resource,
2757
+ );
2758
+ if (inactive.state !== "inactive") {
2759
+ throw new Error("browser cleanup session remained active");
2760
+ }
2761
+ const endpointDeadline = postClose
2762
+ && postCloseEffectDeadline !== null
2763
+ ? Math.min(now() + 5_000, postCloseEffectDeadline)
2764
+ : now() + 5_000;
2765
+ let consecutiveRefusals = 0;
2766
+ let endpointSleeps = 0;
2767
+ while (consecutiveRefusals < 3) {
2768
+ const status = await endpointStatus(control.cdpUrl);
2769
+ if (status === "indeterminate") {
2770
+ throw new Error("browser cleanup endpoint state is indeterminate");
2771
+ }
2772
+ consecutiveRefusals = status === "unavailable"
2773
+ ? consecutiveRefusals + 1
2774
+ : 0;
2775
+ if (consecutiveRefusals >= 3) break;
2776
+ if (
2777
+ endpointSleeps >= (postClose ? 80 : 200)
2778
+ || now() >= endpointDeadline
2779
+ ) {
2780
+ throw new Error("browser cleanup endpoint remained available");
2781
+ }
2782
+ endpointSleeps += 1;
2783
+ await sleep(25);
2784
+ }
2785
+ if (inspectOwner(control.daemonOwner) !== "different-or-dead") {
2786
+ throw new Error("browser cleanup daemon quiescence changed");
2787
+ }
2788
+ const finalInactive = parseAgentBrowserSessionState(
2789
+ await lifecycle.inspectSession(),
2790
+ resource,
2791
+ );
2792
+ if (finalInactive.state !== "inactive") {
2793
+ throw new Error("browser cleanup session quiescence changed");
2794
+ }
2795
+ assertBrowserCleanupResourceRootsMatch(resource);
2796
+ return resource;
2797
+ }
2798
+
2799
+ export async function recoverPinnedAgentBrowserCleanupResource(
2800
+ value: BrowserCleanupResourceIdentityV2,
2801
+ dependencies: AgentBrowserLifecycleDependencies = {},
2802
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2803
+ return recoverPinnedAgentBrowserCleanupResourceCore(
2804
+ value,
2805
+ dependencies,
2806
+ "may-close-browser",
2807
+ );
2808
+ }
2809
+
2810
+ /**
2811
+ * Resume only the cleanup half of a browser close whose acknowledgement was
2812
+ * lost. A still-launched browser is observed again within the existing
2813
+ * teardown bound, but is never closed or signaled by this path. Only two exact
2814
+ * browser-closed observations can reach the pinned-owner SIGTERM recovery.
2815
+ */
2816
+ async function recoverPinnedAgentBrowserCleanupResourceAfterCloseAttempt(
2817
+ value: BrowserCleanupResourceIdentityV2,
2818
+ dependencies: AgentBrowserLifecycleDependencies = {},
2819
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2820
+ const sleep = dependencies.sleep
2821
+ ?? ((milliseconds: number) => Bun.sleep(milliseconds));
2822
+ const suppliedNow = dependencies.now ?? (() => performance.now());
2823
+ let lastNow = Number.NEGATIVE_INFINITY;
2824
+ const now = (): number => {
2825
+ const value = suppliedNow();
2826
+ if (!Number.isFinite(value) || value < lastNow) {
2827
+ throw new Error("browser cleanup monotonic clock is invalid");
2828
+ }
2829
+ lastNow = value;
2830
+ return value;
2831
+ };
2832
+ const deadline = now() + BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS;
2833
+ const retryIntervalMs = 25;
2834
+ const maximumAttempts = Math.ceil(
2835
+ BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS / retryIntervalMs,
2836
+ ) + 1;
2837
+ let attempts = 0;
2838
+ let lastTransitionFailure: unknown = new Error(
2839
+ "browser cleanup post-close convergence did not start",
2840
+ );
2841
+ let terminationAttempted = false;
2842
+ const originalCommandTimeoutMs = dependencies.commandTimeoutMs;
2843
+ const boundedDependencies: AgentBrowserLifecycleDependencies = {
2844
+ ...dependencies,
2845
+ now,
2846
+ commandTimeoutMs: () => Math.max(
2847
+ 1,
2848
+ Math.min(
2849
+ originalCommandTimeoutMs?.() ?? 10_000,
2850
+ Math.max(0, deadline - now()),
2851
+ ),
2852
+ ),
2853
+ };
2854
+ while (
2855
+ attempts < maximumAttempts
2856
+ && (attempts === 0 || now() < deadline)
2857
+ ) {
2858
+ attempts += 1;
2859
+ try {
2860
+ return await recoverPinnedAgentBrowserCleanupResourceCore(
2861
+ value,
2862
+ boundedDependencies,
2863
+ "close-already-attempted",
2864
+ deadline,
2865
+ () => {
2866
+ if (terminationAttempted) {
2867
+ throw new Error(
2868
+ "browser cleanup post-close termination was already attempted",
2869
+ );
2870
+ }
2871
+ terminationAttempted = true;
2872
+ },
2873
+ );
2874
+ } catch (error) {
2875
+ if (terminationAttempted) throw error;
2876
+ let retryable = error
2877
+ instanceof AgentBrowserPostCloseTransitionStillSettlingError;
2878
+ if (error instanceof AgentBrowserLifecycleCommandUnavailableError) {
2879
+ const resource = parseBrowserCleanupResourceIdentity(value);
2880
+ if (
2881
+ resource.kind !== "agent-browser-session-v2"
2882
+ || resource.phase !== "controlled"
2883
+ ) throw error;
2884
+ assertBrowserCleanupResourceRootsMatch(resource);
2885
+ const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
2886
+ const ownerStatus = inspectOwner(resource.control.daemonOwner);
2887
+ if (ownerStatus === "unknown") {
2888
+ throw new Error("browser cleanup daemon state became indeterminate");
2889
+ }
2890
+ retryable = true;
2891
+ }
2892
+ if (!retryable) throw error;
2893
+ lastTransitionFailure = error;
2894
+ if (attempts >= maximumAttempts || now() >= deadline) throw error;
2895
+ await sleep(retryIntervalMs);
2896
+ }
2897
+ }
2898
+ throw lastTransitionFailure;
2899
+ }
2900
+
2901
+ /**
2902
+ * Refresh full-root quiescence without weakening a launch-intent boundary.
2903
+ * Prepared roots require only their exact inactive proof; controlled roots
2904
+ * use the pinned close/TERM/CDP recovery protocol.
2905
+ */
2906
+ export async function refreshBrowserCleanupResourceQuiescence(
2907
+ value: BrowserCleanupResourceIdentityV2,
2908
+ dependencies: AgentBrowserLifecycleDependencies = {},
2909
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2910
+ const resource = parseBrowserCleanupResourceIdentity(value);
2911
+ if (resource.kind !== "agent-browser-session-v2") {
2912
+ throw new Error("browser cleanup resource is not recoverable");
2913
+ }
2914
+ if (resource.phase === "prepared") {
2915
+ return provePreparedAgentBrowserCleanupResourceQuiescent(
2916
+ resource,
2917
+ dependencies,
2918
+ );
2919
+ }
2920
+ if (resource.phase === "controlled") {
2921
+ return recoverPinnedAgentBrowserCleanupResource(resource, dependencies);
2922
+ }
2923
+ throw new Error("browser cleanup launch intent is not durably controlled");
2924
+ }
2925
+
2926
+ function assertBrowserDeletionBoundaryRoots(
2927
+ resource: BrowserCleanupResourceIdentityV2,
2928
+ ): void {
2929
+ if (
2930
+ browserCleanupResourceRootStatus(resource, "socket") !== "match"
2931
+ || browserCleanupResourceRootStatus(resource, "artifacts") !== "absent"
2932
+ ) {
2933
+ throw new Error("browser cleanup deletion-boundary roots changed");
2934
+ }
2935
+ }
2936
+
2937
+ async function proveControlledDeletionBoundary(
2938
+ resource: BrowserCleanupResourceIdentityV2,
2939
+ dependencies: AgentBrowserLifecycleDependencies,
2940
+ ): Promise<void> {
2941
+ if (resource.phase !== "controlled") {
2942
+ throw new Error("browser cleanup resource is not durably controlled");
2943
+ }
2944
+ const inspectOwner = dependencies.ownerStatus ?? processOwnerStatus;
2945
+ if (inspectOwner(resource.control.daemonOwner) !== "different-or-dead") {
2946
+ throw new Error("browser cleanup pinned owner is not quiescent");
2947
+ }
2948
+ const endpointStatus = dependencies.cdpEndpointStatus
2949
+ ?? exactAgentBrowserCdpEndpointStatus;
2950
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2951
+ if (await endpointStatus(resource.control.cdpUrl) !== "unavailable") {
2952
+ throw new Error("browser cleanup endpoint refusal is unproved");
2953
+ }
2954
+ }
2955
+ if (inspectOwner(resource.control.daemonOwner) !== "different-or-dead") {
2956
+ throw new Error("browser cleanup pinned owner quiescence changed");
2957
+ }
2958
+ }
2959
+
2960
+ /**
2961
+ * Re-prove deletion safety after the artifacts root has already been removed.
2962
+ * This helper performs session-info reads only. It never launches, closes, or
2963
+ * signals a browser process, and its temporary config remains inside the exact
2964
+ * socket root that is already scheduled for deletion.
2965
+ */
2966
+ export async function reproveBrowserCleanupAfterArtifactsRemoval(
2967
+ value: BrowserCleanupResourceIdentityV2,
2968
+ dependencies: AgentBrowserLifecycleDependencies = {},
2969
+ ): Promise<BrowserCleanupResourceIdentityV2> {
2970
+ const resource = parseBrowserCleanupResourceIdentity(value);
2971
+ if (
2972
+ resource.kind !== "agent-browser-session-v2"
2973
+ || resource.phase === "launch-intent"
2974
+ ) {
2975
+ throw new Error("browser cleanup resource is ineligible for deletion reproof");
2976
+ }
2977
+ assertBrowserDeletionBoundaryRoots(resource);
2978
+ if (resource.phase === "controlled") {
2979
+ await proveControlledDeletionBoundary(resource, dependencies);
2980
+ }
2981
+ const temporaryConfig = join(
2982
+ resource.socketDirectory,
2983
+ `.wrench-cleanup-${crypto.randomUUID()}.json`,
2984
+ );
2985
+ try {
2986
+ try {
2987
+ writeFileSync(temporaryConfig, "{}\n", {
2988
+ flag: "wx",
2989
+ mode: 0o600,
2990
+ });
2991
+ const stats = lstatSync(temporaryConfig, { bigint: true });
2992
+ const currentUid = process.getuid?.();
2993
+ if (
2994
+ stats.isSymbolicLink()
2995
+ || !stats.isFile()
2996
+ || currentUid === undefined
2997
+ || stats.uid !== BigInt(currentUid)
2998
+ || (stats.mode & 0o777n) !== 0o600n
2999
+ ) throw new Error("temporary cleanup config identity changed");
3000
+ } catch {
3001
+ throw new Error("browser cleanup session inspection could not be prepared");
3002
+ }
3003
+ assertBrowserDeletionBoundaryRoots(resource);
3004
+ const runner = dependencies.runCommand ?? runCommand;
3005
+ const environment = isolatedEnvironment(resource.socketDirectory);
3006
+ const inspectSession = async (): Promise<AgentBrowserSessionState> => {
3007
+ let result: CommandResult;
3008
+ try {
3009
+ result = await runner([
3010
+ ...agentBrowserCommand(),
3011
+ "--config",
3012
+ temporaryConfig,
3013
+ "--session",
3014
+ resource.session,
3015
+ "--content-boundaries",
3016
+ "--max-output",
3017
+ "1048576",
3018
+ "session",
3019
+ "info",
3020
+ "--json",
3021
+ ], {
3022
+ cwd: resource.socketDirectory,
3023
+ environment,
3024
+ timeoutMs: dependencies.commandTimeoutMs?.() ?? 10_000,
3025
+ maxOutputBytes: 1024 * 1024,
3026
+ ...(dependencies.commandSignal === undefined
3027
+ ? {}
3028
+ : { signal: dependencies.commandSignal }),
3029
+ });
3030
+ } catch {
3031
+ throw new Error("browser cleanup session inspection could not be verified");
3032
+ }
3033
+ if (result.exitCode !== 0) {
3034
+ throw new Error("browser cleanup session inspection could not be verified");
3035
+ }
3036
+ let parsed: unknown;
3037
+ try {
3038
+ parsed = parseLastJsonWithExactLaunchHashes(result.stdout);
3039
+ } catch {
3040
+ throw new Error("browser cleanup session inspection changed shape");
3041
+ }
3042
+ return parseAgentBrowserSessionState(parsed, resource);
3043
+ };
3044
+ const first = await inspectSession();
3045
+ if (first.state !== "inactive") {
3046
+ throw new Error("browser cleanup session remained active");
3047
+ }
3048
+ assertBrowserDeletionBoundaryRoots(resource);
3049
+ const second = await inspectSession();
3050
+ if (second.state !== "inactive") {
3051
+ throw new Error("browser cleanup session quiescence changed");
3052
+ }
3053
+ assertBrowserDeletionBoundaryRoots(resource);
3054
+ if (resource.phase === "controlled") {
3055
+ await proveControlledDeletionBoundary(resource, dependencies);
3056
+ }
3057
+ } finally {
3058
+ try {
3059
+ rmSync(temporaryConfig, { force: true });
3060
+ } catch {
3061
+ // A retained mode-0600 config remains inside the root being deleted.
3062
+ }
3063
+ }
3064
+ assertBrowserDeletionBoundaryRoots(resource);
3065
+ return resource;
3066
+ }
3067
+
3068
+ export function agentBrowserFailure(result: CommandResult, context: string): Error {
3069
+ let detail: string | null = null;
3070
+ try {
3071
+ const parsed = parseLastJson(result.stdout);
3072
+ const failures = Array.isArray(parsed) ? parsed : [parsed];
3073
+ for (const failure of failures) {
3074
+ if (typeof failure !== "object" || failure === null || Array.isArray(failure)) continue;
3075
+ const record = failure as Record<string, unknown>;
3076
+ if (record.success === false && typeof record.error === "string") {
3077
+ detail = sanitizeTerminalLine(redactSensitiveText(record.error)).slice(0, 1_000);
3078
+ break;
3079
+ }
3080
+ }
3081
+ } catch {
3082
+ // A structured error is optional; never echo arbitrary stdout or stderr.
3083
+ }
3084
+ return new Error(`${context} failed with exit code ${result.exitCode}${detail === null || detail === "" ? "" : `: ${detail}`}`);
3085
+ }
3086
+
3087
+ function parsedBatch(output: string, expected: number): readonly JsonRecord[] {
3088
+ const parsed = parseLastJson(output);
3089
+ if (!Array.isArray(parsed) || parsed.length !== expected) throw new Error("agent-browser returned a malformed batch result");
3090
+ const records: JsonRecord[] = [];
3091
+ for (const value of parsed) {
3092
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("agent-browser returned a malformed batch entry");
3093
+ records.push(value as JsonRecord);
3094
+ }
3095
+ return records;
3096
+ }
3097
+
3098
+ export {
3099
+ cloneBrowserProfile,
3100
+ cloneProfile,
3101
+ copyBoundedLocalState,
3102
+ isSafeNamedProfile,
3103
+ profilePath,
3104
+ };
3105
+ export type { ClonedBrowserProfile };
3106
+
3107
+ export function browserResultData(record: JsonRecord): unknown {
3108
+ if (record.success !== true) {
3109
+ const error = typeof record.error === "string" ? record.error : "agent-browser command failed";
3110
+ throw new Error(error);
3111
+ }
3112
+ if (Object.hasOwn(record, "result")) return record.result;
3113
+ if (Object.hasOwn(record, "data")) return record.data;
3114
+ throw new Error("agent-browser command omitted its result");
3115
+ }
3116
+
3117
+ const BROWSER_SETUP_DEADLINE_LABEL = "browser session setup";
3118
+ const BROWSER_CLOSE_TEARDOWN_TIMEOUT_MS = 17_500;
3119
+ const BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS = 2_000;
3120
+ const BROWSER_ACTIVE_BATCH_SETTLEMENT_TIMEOUT_MS = 2_500;
3121
+
3122
+ function guardBrowserSetup(deadline: BrowserOperationDeadline | undefined): void {
3123
+ deadline?.throwIfUnavailable(BROWSER_SETUP_DEADLINE_LABEL);
3124
+ }
3125
+
3126
+ function remainingBrowserSetupTime(
3127
+ requestedTimeoutMs: number,
3128
+ deadline: BrowserOperationDeadline | undefined,
3129
+ ): number {
3130
+ if (deadline === undefined) return requestedTimeoutMs;
3131
+ deadline.throwIfUnavailable(BROWSER_SETUP_DEADLINE_LABEL);
3132
+ const remaining = Math.min(requestedTimeoutMs, deadline.remainingTimeMs());
3133
+ if (remaining < 1) {
3134
+ deadline.throwIfUnavailable(BROWSER_SETUP_DEADLINE_LABEL);
3135
+ throw new Error(`${BROWSER_SETUP_DEADLINE_LABEL} timed out`);
3136
+ }
3137
+ return remaining;
3138
+ }
3139
+
3140
+ function runBrowserSetupStep<T>(
3141
+ deadline: BrowserOperationDeadline | undefined,
3142
+ work: () => Promise<T>,
3143
+ ): Promise<T> {
3144
+ if (deadline === undefined) return work();
3145
+ deadline.throwIfUnavailable(BROWSER_SETUP_DEADLINE_LABEL);
3146
+ return deadline.run(
3147
+ () => work(),
3148
+ BROWSER_SETUP_DEADLINE_LABEL,
3149
+ );
3150
+ }
3151
+
3152
+ async function teardownCompletesWithin(
3153
+ teardown: Promise<unknown>,
3154
+ maximumMs: number,
3155
+ ): Promise<boolean> {
3156
+ let timer: ReturnType<typeof setTimeout> | undefined;
3157
+ const observed = teardown.then(
3158
+ () => true as const,
3159
+ () => false as const,
3160
+ );
3161
+ try {
3162
+ return await Promise.race([
3163
+ observed,
3164
+ new Promise<false>((resolve) => {
3165
+ timer = setTimeout(() => resolve(false), maximumMs);
3166
+ }),
3167
+ ]);
3168
+ } finally {
3169
+ if (timer !== undefined) clearTimeout(timer);
3170
+ }
3171
+ }
3172
+
3173
+ function removePrivateArtifacts(
3174
+ paths: readonly (string | null)[],
3175
+ remove: BrowserPrivateArtifactRemover,
3176
+ ): readonly unknown[] {
3177
+ const failures: unknown[] = [];
3178
+ for (const path of paths) {
3179
+ if (path === null) continue;
3180
+ try {
3181
+ remove(path);
3182
+ } catch (error) {
3183
+ failures.push(error);
3184
+ }
3185
+ }
3186
+ return failures;
3187
+ }
3188
+
3189
+ function cleanupFailureCause(
3190
+ operationFailure: unknown,
3191
+ cleanupFailures: readonly unknown[],
3192
+ ): unknown {
3193
+ if (cleanupFailures.length === 0) return operationFailure;
3194
+ return new AggregateError(
3195
+ [operationFailure, ...cleanupFailures],
3196
+ "browser private-artifact cleanup failed",
3197
+ );
3198
+ }
3199
+
3200
+ export async function createBrowserSession(
3201
+ manifest: GhostgetManifest,
3202
+ auth: GhostgetAuth,
3203
+ options: CreateBrowserSessionOptions,
3204
+ ): Promise<BrowserSession> {
3205
+ if (process.platform === "win32") {
3206
+ throw new Error(
3207
+ "contained browser sessions require process-tree containment that is unavailable on Windows",
3208
+ );
3209
+ }
3210
+ const operationDeadline = options.operationDeadline;
3211
+ guardBrowserSetup(operationDeadline);
3212
+ const runBrowserCommand = options.dependencies?.runCommand ?? runCommand;
3213
+ const createNetworkProxy = options.dependencies?.startNetworkProxy ?? startNetworkProxy;
3214
+ const readCookies = options.dependencies?.acquireCookieRecords ?? acquireCookieRecords;
3215
+ const removePrivateArtifact = options.dependencies?.removePrivateArtifact
3216
+ ?? ((path: string): void => rmSync(path, { recursive: true, force: true }));
3217
+ guardBrowserSetup(operationDeadline);
3218
+ const session = `io-${process.pid}-${crypto.randomUUID().slice(0, 12)}`;
3219
+ const directory = mkdtempSync(join(tmpdir(), "io-browser-"));
3220
+ const configPath = join(directory, "agent-browser.json");
3221
+ const policyPath = join(directory, "action-policy.json");
3222
+ const globalArguments: string[] = [];
3223
+ let selectedProfileDirectory: "Default" | null = null;
3224
+ let socketDirectory: string | null = null;
3225
+ let cleanupResourceIdentity: BrowserCleanupResourceIdentityV2 | null = null;
3226
+ let cleanupResourcePublication:
3227
+ | "unpublished"
3228
+ | "uncertain"
3229
+ | "published" = "unpublished";
3230
+ const recoveryHandle = (): string => browserRecoveryHandle({
3231
+ session,
3232
+ configPath,
3233
+ socketDirectory,
3234
+ artifactsDirectory: directory,
3235
+ });
3236
+ const failInitialization = (
3237
+ error: unknown,
3238
+ cleanupEvidenceResource: BrowserCleanupResourceIdentity | null =
3239
+ cleanupResourceIdentity,
3240
+ ): never => {
3241
+ if (cleanupResourcePublication !== "unpublished") {
3242
+ throw new PreservedBrowserArtifactsError(
3243
+ "browser session initialization failed after its cleanup roots may have been durably published",
3244
+ recoveryHandle(),
3245
+ error,
3246
+ cleanupEvidenceResource === null
3247
+ ? undefined
3248
+ : Object.freeze({
3249
+ kind: "agent-browser-closed-artifacts-v1" as const,
3250
+ resource: cleanupEvidenceResource,
3251
+ }),
3252
+ );
3253
+ }
3254
+ const cleanupFailures = removePrivateArtifacts(
3255
+ [socketDirectory, directory],
3256
+ removePrivateArtifact,
3257
+ );
3258
+ if (cleanupFailures.length > 0) {
3259
+ throw new PreservedBrowserArtifactsError(
3260
+ "browser session initialization failed and private artifacts were preserved because rollback was incomplete",
3261
+ recoveryHandle(),
3262
+ cleanupFailureCause(error, cleanupFailures),
3263
+ );
3264
+ }
3265
+ throw error;
3266
+ };
3267
+ try {
3268
+ globalArguments.push(
3269
+ "--config",
3270
+ configPath,
3271
+ "--session",
3272
+ session,
3273
+ "--content-boundaries",
3274
+ "--max-output",
3275
+ String(options.maxOutputBytes),
3276
+ "--action-policy",
3277
+ policyPath,
3278
+ ...(auth.kind === "browser-profile" && auth.browserExecutable !== undefined
3279
+ ? ["--executable-path", auth.browserExecutable]
3280
+ : []),
3281
+ ...(options.headed ? ["--headed"] : []),
3282
+ );
3283
+ if (auth.kind !== "browser-profile") {
3284
+ globalArguments.push("--allowed-domains", manifest.browserDomains.join(","));
3285
+ }
3286
+ guardBrowserSetup(operationDeadline);
3287
+ chmodSync(directory, 0o700);
3288
+ guardBrowserSetup(operationDeadline);
3289
+ socketDirectory = mkdtempSync(join("/tmp", "io-ab-"));
3290
+ guardBrowserSetup(operationDeadline);
3291
+ chmodSync(socketDirectory, 0o700);
3292
+ guardBrowserSetup(operationDeadline);
3293
+ writeFileSync(configPath, "{}\n", { mode: 0o600, flag: "wx" });
3294
+ guardBrowserSetup(operationDeadline);
3295
+ writeFileSync(policyPath, `${JSON.stringify({
3296
+ default: "deny",
3297
+ // Pinned agent-browser 0.32.3 checks both documented categories and
3298
+ // concrete commands such as getbyrole, inputvalue, and waitfortext.
3299
+ allow: [
3300
+ ...runtimeBrowserPolicyActions,
3301
+ ...(options.allowCodeOwnedEvaluation === true ? ["evaluate"] : []),
3302
+ ...(options.allowCodeOwnedNetworkObservation === true ? ["network", "requests", "request"] : []),
3303
+ ],
3304
+ })}\n`, { mode: 0o600, flag: "wx" });
3305
+ } catch (error) {
3306
+ failInitialization(error);
3307
+ }
3308
+ const initializedSocketDirectory = socketDirectory
3309
+ ?? failInitialization(new Error("browser socket directory was not initialized"));
3310
+ cleanupResourceIdentity = (() => {
3311
+ let identity: BrowserCleanupResourceIdentityV2 | null = null;
3312
+ try {
3313
+ identity = browserCleanupResourceIdentity({
3314
+ recoveryHandle: recoveryHandle(),
3315
+ session,
3316
+ socketDirectory: initializedSocketDirectory,
3317
+ artifactsDirectory: directory,
3318
+ });
3319
+ // A throwing publisher may have committed before reporting failure. The
3320
+ // durable registrar normally reconciles that exact commit, but custom or
3321
+ // interrupted publishers cannot disprove it. Preserve both roots.
3322
+ cleanupResourcePublication = options.publishCleanupResource === undefined
3323
+ ? "unpublished"
3324
+ : "uncertain";
3325
+ options.publishCleanupResource?.(identity);
3326
+ cleanupResourcePublication = options.publishCleanupResource === undefined
3327
+ ? "unpublished"
3328
+ : "published";
3329
+ return identity;
3330
+ } catch (error) {
3331
+ return failInitialization(error, identity);
3332
+ }
3333
+ })();
3334
+ try {
3335
+ guardBrowserSetup(operationDeadline);
3336
+ const sourceProfile = auth.kind === "browser-profile"
3337
+ ? profilePath(auth.profile)
3338
+ : null;
3339
+ if (sourceProfile !== null) {
3340
+ guardBrowserSetup(operationDeadline);
3341
+ const clonedProfile = cloneBrowserProfile(sourceProfile, directory);
3342
+ guardBrowserSetup(operationDeadline);
3343
+ globalArguments.push("--profile", clonedProfile.userDataPath);
3344
+ selectedProfileDirectory = clonedProfile.profileDirectory ?? null;
3345
+ } else if (auth.kind === "browser-profile") {
3346
+ guardBrowserSetup(operationDeadline);
3347
+ globalArguments.push("--profile", auth.profile);
3348
+ }
3349
+ } catch (error) {
3350
+ failInitialization(error);
3351
+ }
3352
+ if (cleanupResourceIdentity === null) {
3353
+ failInitialization(new Error("browser cleanup resource was not initialized"));
3354
+ }
3355
+ const publishCleanupResourceExtension = (
3356
+ next: BrowserCleanupResourceIdentityV2,
3357
+ ): void => {
3358
+ const current = cleanupResourceIdentity;
3359
+ if (current === null || !browserCleanupResourceExtends(current, next)) {
3360
+ throw new Error("browser cleanup resource extension is not monotonic");
3361
+ }
3362
+ if (options.publishCleanupResource !== undefined) {
3363
+ cleanupResourcePublication = "uncertain";
3364
+ options.publishCleanupResource(next);
3365
+ cleanupResourcePublication = "published";
3366
+ }
3367
+ cleanupResourceIdentity = next;
3368
+ };
3369
+ let environment: Readonly<Record<string, string>>;
3370
+ try {
3371
+ environment = isolatedEnvironment(initializedSocketDirectory);
3372
+ } catch (error) {
3373
+ failInitialization(error);
3374
+ }
3375
+ let networkProxy: LocalNetworkProxy | null = null;
3376
+ const networkProxyCreation: {
3377
+ pending: Promise<LocalNetworkProxy> | null;
3378
+ } = { pending: null };
3379
+ let closeDisposition:
3380
+ | "open"
3381
+ | "acknowledged"
3382
+ | "independently-proved-quiescent" = "open";
3383
+ let closeRecoveryDisposition:
3384
+ | "ineligible"
3385
+ | "retry-after-close-attempt" = "ineligible";
3386
+ let cleanupConvergenceAttempted = false;
3387
+ const sessionIsClosed = (): boolean => closeDisposition !== "open";
3388
+ let closeOperation: Promise<void> | null = null;
3389
+ let launchAttempted = false;
3390
+ const activeBatches = new Set<Promise<readonly JsonRecord[]>>();
3391
+ let unsafeCommandCleanup: BrowserCommandCleanupError | null = null;
3392
+ const runBatch = async (
3393
+ commands: readonly (readonly string[])[],
3394
+ timeoutMs: number,
3395
+ maxOutputBytes: number,
3396
+ ): Promise<readonly JsonRecord[]> => {
3397
+ if (sessionIsClosed() || closeOperation !== null) {
3398
+ throw new Error("browser session is closed");
3399
+ }
3400
+ return runBrowserSetupStep(
3401
+ operationDeadline,
3402
+ () => {
3403
+ const batch = (async (): Promise<readonly JsonRecord[]> => {
3404
+ const result = await runBrowserCommand(
3405
+ [...agentBrowserCommand(), ...globalArguments, "batch", "--bail", "--json"],
3406
+ {
3407
+ cwd: directory,
3408
+ environment,
3409
+ timeoutMs: remainingBrowserSetupTime(timeoutMs, operationDeadline),
3410
+ maxOutputBytes,
3411
+ stdin: JSON.stringify(commands),
3412
+ ...(operationDeadline === undefined
3413
+ ? {}
3414
+ : { signal: operationDeadline.signal }),
3415
+ },
3416
+ );
3417
+ if (result.exitCode !== 0) throw agentBrowserFailure(result, "agent-browser batch");
3418
+ const entries = parsedBatch(result.stdout, commands.length);
3419
+ for (const entry of entries) browserResultData(entry);
3420
+ return entries;
3421
+ })();
3422
+ activeBatches.add(batch);
3423
+ void batch.then(
3424
+ () => activeBatches.delete(batch),
3425
+ (error: unknown) => {
3426
+ if (error instanceof BrowserCommandCleanupError) {
3427
+ unsafeCommandCleanup = error;
3428
+ }
3429
+ activeBatches.delete(batch);
3430
+ },
3431
+ );
3432
+ return batch;
3433
+ },
3434
+ );
3435
+ };
3436
+ const close = (): Promise<void> => {
3437
+ if (sessionIsClosed()) return Promise.resolve();
3438
+ if (closeOperation !== null) return closeOperation;
3439
+ closeOperation = (async () => {
3440
+ if (activeBatches.size > 0) {
3441
+ const settled = await teardownCompletesWithin(
3442
+ Promise.allSettled([...activeBatches]),
3443
+ BROWSER_ACTIVE_BATCH_SETTLEMENT_TIMEOUT_MS,
3444
+ );
3445
+ if (!settled || activeBatches.size > 0) {
3446
+ throw new Error(
3447
+ "refusing to close the browser while a batch command remains active",
3448
+ );
3449
+ }
3450
+ }
3451
+ if (unsafeCommandCleanup !== null) {
3452
+ throw unsafeCommandCleanup;
3453
+ }
3454
+ let closeFailure: unknown;
3455
+ try {
3456
+ const result = await runBrowserCommand(
3457
+ [...agentBrowserCommand(), ...globalArguments, "close", "--json"],
3458
+ { cwd: directory, environment, timeoutMs: 15_000, maxOutputBytes: 1024 * 1024 },
3459
+ );
3460
+ if (result.exitCode === 0) {
3461
+ closeDisposition = "acknowledged";
3462
+ return;
3463
+ }
3464
+ closeFailure = agentBrowserFailure(result, "agent-browser close");
3465
+ } catch (error) {
3466
+ if (error instanceof BrowserCommandCleanupError) throw error;
3467
+ closeFailure = error;
3468
+ }
3469
+ const resource = cleanupResourceIdentity;
3470
+ if (
3471
+ resource === null
3472
+ || resource.kind !== "agent-browser-session-v2"
3473
+ || resource.phase !== "controlled"
3474
+ ) throw closeFailure;
3475
+ try {
3476
+ await provePinnedAgentBrowserCleanupResourceQuiescentWithoutEffects(
3477
+ resource,
3478
+ {
3479
+ ...options.dependencies?.cleanupLifecycle,
3480
+ runCommand: runBrowserCommand,
3481
+ },
3482
+ );
3483
+ } catch (quiescenceFailure) {
3484
+ if (
3485
+ quiescenceFailure instanceof AgentBrowserCleanupOwnerStillLiveError
3486
+ || quiescenceFailure
3487
+ instanceof AgentBrowserLifecycleCommandUnavailableError
3488
+ ) {
3489
+ closeRecoveryDisposition = "retry-after-close-attempt";
3490
+ }
3491
+ throw new AggregateError(
3492
+ [closeFailure, quiescenceFailure],
3493
+ "browser session close was neither acknowledged nor independently proved quiescent",
3494
+ );
3495
+ }
3496
+ closeDisposition = "independently-proved-quiescent";
3497
+ })();
3498
+ return closeOperation;
3499
+ };
3500
+ const cleanup = async (): Promise<void> => {
3501
+ if (!sessionIsClosed() && closeOperation === null) {
3502
+ throw new Error("refusing to delete private browser artifacts before the session closes");
3503
+ }
3504
+ const failures: unknown[] = [];
3505
+ if (!sessionIsClosed() && closeOperation !== null) {
3506
+ try {
3507
+ await closeOperation;
3508
+ } catch {
3509
+ // close() retains and reports its exact failure. cleanup() may only
3510
+ // wait for exact post-close convergence and may terminate only a
3511
+ // twice-observed browser-closed pinned daemon.
3512
+ }
3513
+ if (
3514
+ !sessionIsClosed()
3515
+ && closeRecoveryDisposition === "retry-after-close-attempt"
3516
+ && !cleanupConvergenceAttempted
3517
+ && unsafeCommandCleanup === null
3518
+ && activeBatches.size === 0
3519
+ ) {
3520
+ cleanupConvergenceAttempted = true;
3521
+ const resource = cleanupResourceIdentity;
3522
+ if (
3523
+ resource === null
3524
+ || resource.kind !== "agent-browser-session-v2"
3525
+ || resource.phase !== "controlled"
3526
+ ) {
3527
+ failures.push(new Error(
3528
+ "browser cleanup resource identity is unavailable for close convergence",
3529
+ ));
3530
+ } else {
3531
+ try {
3532
+ await recoverPinnedAgentBrowserCleanupResourceAfterCloseAttempt(
3533
+ resource,
3534
+ {
3535
+ ...options.dependencies?.cleanupLifecycle,
3536
+ runCommand: runBrowserCommand,
3537
+ },
3538
+ );
3539
+ closeDisposition = "independently-proved-quiescent";
3540
+ } catch (error) {
3541
+ failures.push(error);
3542
+ }
3543
+ }
3544
+ }
3545
+ }
3546
+ let resourcesQuiescent = true;
3547
+ if (networkProxy !== null) {
3548
+ const proxy = networkProxy;
3549
+ if (await teardownCompletesWithin(
3550
+ Promise.resolve().then(() => proxy.close()),
3551
+ BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS,
3552
+ )) {
3553
+ if (networkProxy === proxy) networkProxy = null;
3554
+ } else {
3555
+ resourcesQuiescent = false;
3556
+ failures.push(
3557
+ new Error("browser network proxy cleanup did not settle safely"),
3558
+ );
3559
+ }
3560
+ }
3561
+ const rootsAreUnused = sessionIsClosed() && activeBatches.size === 0;
3562
+ if (!rootsAreUnused) {
3563
+ failures.push(
3564
+ new Error(
3565
+ "refusing to delete private browser artifacts before the session closes",
3566
+ ),
3567
+ );
3568
+ }
3569
+ if (resourcesQuiescent && rootsAreUnused) {
3570
+ const resource = cleanupResourceIdentity;
3571
+ const publisher = options.publishCleanupResource;
3572
+ const markQuiescent = publisher?.markBrowserCleanupQuiescent;
3573
+ const markRootRemoved = publisher?.markBrowserCleanupRootRemoved;
3574
+ if (resource === null) {
3575
+ failures.push(new Error("browser cleanup resource identity is unavailable"));
3576
+ } else if (publisher !== undefined) {
3577
+ if (
3578
+ markQuiescent === undefined
3579
+ || markRootRemoved === undefined
3580
+ ) {
3581
+ failures.push(new Error(
3582
+ "durable browser cleanup journaling is unavailable",
3583
+ ));
3584
+ } else {
3585
+ try {
3586
+ // This transition must commit while both roots still match. Once
3587
+ // durable, recovery may accept an absent root as a crash between
3588
+ // its removal and the following journal CAS.
3589
+ await refreshBrowserCleanupResourceQuiescence(
3590
+ resource,
3591
+ {
3592
+ ...options.dependencies?.cleanupLifecycle,
3593
+ runCommand: runBrowserCommand,
3594
+ },
3595
+ );
3596
+ markQuiescent(resource);
3597
+ } catch (error) {
3598
+ failures.push(error);
3599
+ }
3600
+ }
3601
+ }
3602
+ if (failures.length === 0 && resource !== null) {
3603
+ for (const [rootName, path] of [
3604
+ ["artifacts", directory],
3605
+ ["socket", initializedSocketDirectory],
3606
+ ] as const) {
3607
+ try {
3608
+ removePrivateArtifact(path);
3609
+ if (browserCleanupResourceRootStatus(resource, rootName) !== "absent") {
3610
+ throw new Error(
3611
+ "browser cleanup root removal could not be verified",
3612
+ );
3613
+ }
3614
+ } catch (error) {
3615
+ failures.push(error);
3616
+ break;
3617
+ }
3618
+ if (publisher !== undefined && markRootRemoved !== undefined) {
3619
+ try {
3620
+ markRootRemoved(resource, rootName);
3621
+ } catch (error) {
3622
+ failures.push(error);
3623
+ break;
3624
+ }
3625
+ }
3626
+ if (rootName === "artifacts" && publisher !== undefined) {
3627
+ try {
3628
+ await reproveBrowserCleanupAfterArtifactsRemoval(
3629
+ resource,
3630
+ {
3631
+ ...options.dependencies?.cleanupLifecycle,
3632
+ runCommand: runBrowserCommand,
3633
+ },
3634
+ );
3635
+ } catch (error) {
3636
+ failures.push(error);
3637
+ break;
3638
+ }
3639
+ }
3640
+ }
3641
+ }
3642
+ }
3643
+ if (failures.length > 0) {
3644
+ throw new AggregateError(
3645
+ failures,
3646
+ "browser session cleanup did not remove every private resource",
3647
+ );
3648
+ }
3649
+ };
3650
+ try {
3651
+ networkProxy = await runBrowserSetupStep(operationDeadline, () => {
3652
+ const creation = createNetworkProxy({
3653
+ allowPrivateNetwork: false,
3654
+ timeoutMs: remainingBrowserSetupTime(options.timeoutMs, operationDeadline),
3655
+ maxTransferredBytes: 1024 * 1024 * 1024,
3656
+ });
3657
+ networkProxyCreation.pending = creation;
3658
+ return creation;
3659
+ });
3660
+ networkProxyCreation.pending = null;
3661
+ guardBrowserSetup(operationDeadline);
3662
+ const proxyArguments = ownedBrowserProxyArguments(
3663
+ networkProxy.url,
3664
+ selectedProfileDirectory ?? undefined,
3665
+ );
3666
+ guardBrowserSetup(operationDeadline);
3667
+ globalArguments.push(...proxyArguments);
3668
+ const launchUrl = auth.kind === "browser-profile"
3669
+ ? "about:blank"
3670
+ : manifest.origins[0];
3671
+ if (launchUrl === undefined) throw new Error("contained browser session requires one reviewed origin");
3672
+ guardBrowserSetup(operationDeadline);
3673
+ const preparedResource = cleanupResourceIdentity;
3674
+ if (preparedResource === null || preparedResource.phase !== "prepared") {
3675
+ throw new Error("browser cleanup resource is not prepared for launch");
3676
+ }
3677
+ const launchIntentResource = parseBrowserCleanupResourceIdentity({
3678
+ ...preparedResource,
3679
+ phase: "launch-intent",
3680
+ control: null,
3681
+ });
3682
+ if (launchIntentResource.kind !== "agent-browser-session-v2") {
3683
+ throw new Error("browser cleanup launch intent is malformed");
3684
+ }
3685
+ publishCleanupResourceExtension(launchIntentResource);
3686
+ launchAttempted = true;
3687
+ await runBatch(
3688
+ [["open", launchUrl]],
3689
+ remainingBrowserSetupTime(options.timeoutMs, operationDeadline),
3690
+ options.maxOutputBytes,
3691
+ );
3692
+ if (options.publishCleanupResource !== undefined) {
3693
+ guardBrowserSetup(operationDeadline);
3694
+ const pinnedCleanupResource = await runBrowserSetupStep(
3695
+ operationDeadline,
3696
+ () => bindLiveAgentBrowserCleanupResource(
3697
+ launchIntentResource,
3698
+ {
3699
+ runCommand: runBrowserCommand,
3700
+ ...(operationDeadline === undefined
3701
+ ? {}
3702
+ : {
3703
+ commandSignal: operationDeadline.signal,
3704
+ commandTimeoutMs: () => remainingBrowserSetupTime(
3705
+ options.timeoutMs,
3706
+ operationDeadline,
3707
+ ),
3708
+ }),
3709
+ },
3710
+ ),
3711
+ );
3712
+ guardBrowserSetup(operationDeadline);
3713
+ publishCleanupResourceExtension(pinnedCleanupResource);
3714
+ }
3715
+ if (
3716
+ auth.kind === "cookie-source"
3717
+ || auth.kind === "cookies-file"
3718
+ || (auth.kind === "browser-profile" && auth.cookieSource !== undefined)
3719
+ ) {
3720
+ for (const origin of manifest.origins) {
3721
+ guardBrowserSetup(operationDeadline);
3722
+ const target = new URL(origin);
3723
+ const cookieResult = await runBrowserSetupStep(
3724
+ operationDeadline,
3725
+ () => readCookies({
3726
+ cookieSources: auth.kind === "cookie-source"
3727
+ ? [auth.source]
3728
+ : auth.kind === "browser-profile" && auth.cookieSource !== undefined
3729
+ ? [auth.cookieSource]
3730
+ : [],
3731
+ cookieProfile: auth.kind === "cookie-source"
3732
+ ? auth.profile
3733
+ : auth.kind === "browser-profile"
3734
+ ? auth.cookieProfile
3735
+ : undefined,
3736
+ cookiesFile: auth.kind === "cookies-file" ? auth.path : undefined,
3737
+ timeoutMs: remainingBrowserSetupTime(options.timeoutMs, operationDeadline),
3738
+ requireExplicitCookieScope: true,
3739
+ }, target),
3740
+ );
3741
+ guardBrowserSetup(operationDeadline);
3742
+ const cookieCommands = browserCookieCommands(cookieResult.cookies, target);
3743
+ guardBrowserSetup(operationDeadline);
3744
+ await runBatch(
3745
+ cookieCommands,
3746
+ remainingBrowserSetupTime(options.timeoutMs, operationDeadline),
3747
+ options.maxOutputBytes,
3748
+ );
3749
+ }
3750
+ }
3751
+ guardBrowserSetup(operationDeadline);
3752
+ return {
3753
+ runBatch,
3754
+ close,
3755
+ cleanup,
3756
+ recoveryHandle: recoveryHandle(),
3757
+ ...(options.publishCleanupResource === undefined
3758
+ ? {}
3759
+ : {
3760
+ cleanupResourceIdentity: cleanupResourceIdentity
3761
+ ?? failInitialization(
3762
+ new Error("browser cleanup resource identity is unavailable"),
3763
+ ),
3764
+ }),
3765
+ };
3766
+ } catch (error) {
3767
+ const cleanupFailures: unknown[] = [];
3768
+ let resourcesQuiescent = true;
3769
+ let commandCleanupUnsafe = error instanceof BrowserCommandCleanupError;
3770
+ if (networkProxyCreation.pending !== null) {
3771
+ const closeLateProxy = networkProxyCreation.pending.then(
3772
+ (proxy) => proxy.close(),
3773
+ () => undefined,
3774
+ );
3775
+ if (!await teardownCompletesWithin(
3776
+ closeLateProxy,
3777
+ BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS,
3778
+ )) {
3779
+ resourcesQuiescent = false;
3780
+ cleanupFailures.push(
3781
+ new Error("browser setup proxy creation did not settle safely"),
3782
+ );
3783
+ }
3784
+ networkProxyCreation.pending = null;
3785
+ }
3786
+ if (!launchAttempted) closeDisposition = "acknowledged";
3787
+ if (launchAttempted && !commandCleanupUnsafe) {
3788
+ if (!await teardownCompletesWithin(
3789
+ close(),
3790
+ BROWSER_CLOSE_TEARDOWN_TIMEOUT_MS,
3791
+ )) {
3792
+ cleanupFailures.push(
3793
+ new Error("browser close could not be verified after setup failure"),
3794
+ );
3795
+ }
3796
+ if (unsafeCommandCleanup !== null) {
3797
+ commandCleanupUnsafe = true;
3798
+ cleanupFailures.push(
3799
+ new Error("a late browser command cleanup failure was preserved"),
3800
+ );
3801
+ }
3802
+ } else if (commandCleanupUnsafe) {
3803
+ cleanupFailures.push(
3804
+ new Error("browser command cleanup could not be verified"),
3805
+ );
3806
+ }
3807
+ if (networkProxy !== null) {
3808
+ const proxy = networkProxy;
3809
+ if (await teardownCompletesWithin(
3810
+ Promise.resolve().then(() => proxy.close()),
3811
+ BROWSER_RESOURCE_TEARDOWN_TIMEOUT_MS,
3812
+ )) {
3813
+ if (networkProxy === proxy) networkProxy = null;
3814
+ } else {
3815
+ resourcesQuiescent = false;
3816
+ cleanupFailures.push(
3817
+ new Error("browser network proxy could not be closed safely"),
3818
+ );
3819
+ }
3820
+ }
3821
+ if (cleanupResourcePublication !== "unpublished") {
3822
+ cleanupFailures.push(
3823
+ new Error("durably published browser roots require exact recovery"),
3824
+ );
3825
+ } else if (
3826
+ resourcesQuiescent
3827
+ && sessionIsClosed()
3828
+ && activeBatches.size === 0
3829
+ && !commandCleanupUnsafe
3830
+ ) {
3831
+ cleanupFailures.push(...removePrivateArtifacts(
3832
+ [initializedSocketDirectory, directory],
3833
+ removePrivateArtifact,
3834
+ ));
3835
+ } else {
3836
+ cleanupFailures.push(
3837
+ new Error("browser private roots remain in use by an unclosed session"),
3838
+ );
3839
+ }
3840
+ if (cleanupFailures.length > 0) {
3841
+ throw new PreservedBrowserArtifactsError(
3842
+ "browser session setup failed and private artifacts were preserved because cleanup could not be verified",
3843
+ recoveryHandle(),
3844
+ cleanupFailureCause(error, cleanupFailures),
3845
+ );
3846
+ }
3847
+ throw error;
3848
+ }
3849
+ }
3850
+
3851
+ /** Retired DOM-action boundary. Browsers remain capture/bootstrap-only. */
3852
+ export function executeBrowserRecipe(
3853
+ manifest: GhostgetManifest,
3854
+ recipe: BrowserRecipe,
3855
+ input: OperationInput,
3856
+ auth: GhostgetAuth,
3857
+ options: {
3858
+ readonly headed: boolean;
3859
+ readonly createSession?: typeof createBrowserSession;
3860
+ readonly fileResolver?: BrowserFileResolver;
3861
+ /** Called after origin validation and before a dispatch command can start. */
3862
+ readonly beforeDispatch?: (event: BrowserDispatchEvent) => Promise<void>;
3863
+ /** Called only after every observation in the matching verification group succeeds. */
3864
+ readonly afterDispatchVerified?: (event: BrowserDispatchEvent) => Promise<void>;
3865
+ },
3866
+ ): Promise<BrowserExecution> {
3867
+ void manifest;
3868
+ void recipe;
3869
+ void input;
3870
+ void auth;
3871
+ void options;
3872
+ return Promise.reject(new Error(DOM_ACTION_TRANSPORT_DISABLED_MESSAGE));
3873
+ }