@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
@@ -0,0 +1,2344 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { types as nodeTypes } from "node:util";
4
+
5
+ import { canonicalJson, sha256 } from "./canonical-json";
6
+ import {
7
+ AgentBrowserLiveControlUnavailableError,
8
+ adoptLiveLegacyBrowserCleanupResource,
9
+ browserCleanupResourceExtends,
10
+ browserCleanupResourceRootStatus,
11
+ bindLiveAgentBrowserCleanupResource,
12
+ parseBrowserCleanupResourceIdentity,
13
+ PreservedBrowserArtifactsError,
14
+ provePreparedAgentBrowserCleanupResourceQuiescent,
15
+ recoverPinnedAgentBrowserCleanupResource,
16
+ refreshBrowserCleanupResourceQuiescence,
17
+ reproveBrowserCleanupAfterArtifactsRemoval,
18
+ type AgentBrowserLifecycleDependencies,
19
+ type BrowserCleanupResourceIdentity,
20
+ type BrowserCleanupResourceIdentityV2,
21
+ } from "./browser";
22
+ import {
23
+ currentProcessStartIdentity,
24
+ processOwnerStatus,
25
+ type ProcessOwnerIdentity,
26
+ type ProcessOwnerStatus,
27
+ } from "./process-identity";
28
+ import {
29
+ localCliCleanupProcessGroupStatus,
30
+ localCliCleanupResourceExtends,
31
+ parseLocalCliCleanupResourceIdentityV1,
32
+ type ProviderPluginCleanupResourceIdentity,
33
+ } from "./provider-plugin-cleanup-resource";
34
+ import {
35
+ createPrivateJsonIfAbsent,
36
+ ensurePrivateStateDirectory,
37
+ ghostgetStateHome,
38
+ readPrivateStateFileIfPresent,
39
+ readPrivateStateFilesBatched,
40
+ removePrivateDirectoryTree,
41
+ removePrivateStateFileIfUnchanged,
42
+ snapshotPrivateStateDirectory,
43
+ writePrivateJsonIfUnchanged,
44
+ } from "./storage";
45
+ import type {
46
+ WebSessionCleanupBarrierRegistrar,
47
+ } from "./web-session-execution";
48
+
49
+ type Environment = Readonly<Record<string, string | undefined>>;
50
+ type JsonRecord = Record<string, unknown>;
51
+
52
+ const LEGACY_ADMISSION_SCHEMA_VERSION = 1 as const;
53
+ const ADMISSION_SCHEMA_VERSION = 2 as const;
54
+ const MAX_ADMISSION_BYTES = 64 * 1024;
55
+ const MAX_ACQUISITION_ATTEMPTS = 8;
56
+ const digestPattern = /^[a-f0-9]{64}$/u;
57
+ const idPattern = /^[a-z][a-z0-9-]{0,127}$/u;
58
+ const authIdPattern = /^[a-z][a-z0-9-]{0,47}$/u;
59
+ const uuidPattern =
60
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
61
+ const claimNamePattern = /^([a-f0-9]{64})\.json$/u;
62
+ const storageHelperArtifactPatterns = Object.freeze([
63
+ /^\.io-write-[1-9][0-9]{0,9}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u,
64
+ /^\.io-mutation-[a-f0-9]{64}-(?:waiting|candidate|held)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.lock$/u,
65
+ /^\.io-mutation-stage-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}-[1-9][0-9]{0,9}\.tmp$/u,
66
+ /^\.io-remove-file-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.quarantine$/u,
67
+ ]);
68
+
69
+ export const WEB_SESSION_CLEANUP_ADMISSION_STATE_DIRECTORY =
70
+ "provider-plugin-state/.web-session-cleanup-admissions" as const;
71
+
72
+ export type WebSessionCleanupAdmissionIdentity = {
73
+ readonly runId: string;
74
+ readonly pluginId: string;
75
+ readonly pluginVersion: string;
76
+ readonly pluginImplementationHash: string;
77
+ readonly adapterId: string;
78
+ readonly adapterHash: string;
79
+ readonly surfaceId: string;
80
+ readonly authId: string;
81
+ readonly authHash: string;
82
+ /** Present for new claims; omitted only by bounded predecessor readers. */
83
+ readonly transport?: "web-session-api" | "local-cli";
84
+ /** Exact plugin/tool/artifact execution identity for this admitted run. */
85
+ readonly executionIdentityHash?: string;
86
+ };
87
+
88
+ export type WebSessionCleanupAdmissionContainment =
89
+ | { readonly status: "parent-owned" }
90
+ | {
91
+ readonly status:
92
+ | "resource-active"
93
+ | "cleanup-complete"
94
+ | "cleanup-unsafe";
95
+ };
96
+
97
+ export type WebSessionCleanupAdmissionResource =
98
+ | {
99
+ readonly resourceId: string;
100
+ readonly status: "unpublished";
101
+ }
102
+ | {
103
+ readonly resourceId: string;
104
+ readonly status: "active";
105
+ readonly identity: ProviderPluginCleanupResourceIdentity;
106
+ }
107
+ | {
108
+ readonly resourceId: string;
109
+ readonly status: "browser-closed-artifacts";
110
+ readonly identity: BrowserCleanupResourceIdentity;
111
+ }
112
+ | {
113
+ readonly resourceId: string;
114
+ /**
115
+ * The pinned browser lifecycle has reached a durable quiescent boundary.
116
+ * Root removal progress is journaled so a crash after either exact
117
+ * deletion can resume without weakening the lifecycle proof.
118
+ */
119
+ readonly status: "browser-quiescent-artifacts";
120
+ readonly identity: BrowserCleanupResourceIdentityV2;
121
+ readonly removedRoots: readonly BrowserCleanupRootName[];
122
+ };
123
+
124
+ export type BrowserCleanupRootName = "artifacts" | "socket";
125
+
126
+ type CleanupAdmissionOwner = ProcessOwnerIdentity & {
127
+ readonly token: string;
128
+ };
129
+
130
+ export type WebSessionCleanupAdmissionRecovery =
131
+ | { readonly status: "idle" }
132
+ | {
133
+ readonly status: "active";
134
+ readonly owner: CleanupAdmissionOwner;
135
+ readonly acquiredAt: string;
136
+ };
137
+
138
+ type WebSessionCleanupAdmissionClaimFields = {
139
+ readonly realmKey: string;
140
+ readonly runId: string;
141
+ readonly pluginId: string;
142
+ readonly pluginVersion: string;
143
+ readonly pluginImplementationHash: string;
144
+ readonly adapterId: string;
145
+ readonly adapterHash: string;
146
+ readonly surfaceId: string;
147
+ readonly authId: string;
148
+ readonly authHash: string;
149
+ readonly transport?: "web-session-api" | "local-cli";
150
+ readonly executionIdentityHash?: string;
151
+ readonly owner: CleanupAdmissionOwner;
152
+ readonly acquiredAt: string;
153
+ readonly containment: WebSessionCleanupAdmissionContainment;
154
+ readonly resources: readonly WebSessionCleanupAdmissionResource[];
155
+ };
156
+
157
+ export type WebSessionCleanupAdmissionClaim =
158
+ | (WebSessionCleanupAdmissionClaimFields & {
159
+ readonly schemaVersion: typeof LEGACY_ADMISSION_SCHEMA_VERSION;
160
+ })
161
+ | (WebSessionCleanupAdmissionClaimFields & {
162
+ readonly schemaVersion: typeof ADMISSION_SCHEMA_VERSION;
163
+ readonly recovery: WebSessionCleanupAdmissionRecovery;
164
+ });
165
+
166
+ export type WebSessionCleanupAdmissionSnapshot = {
167
+ readonly claim: WebSessionCleanupAdmissionClaim;
168
+ readonly contentSha256: string;
169
+ };
170
+
171
+ export type WebSessionCleanupAdmissionListEntry =
172
+ | WebSessionCleanupAdmissionSnapshot
173
+ | {
174
+ readonly coordinate: string;
175
+ readonly invalid: true;
176
+ };
177
+
178
+ export type WebSessionCleanupAdmissionRecoveryIssue = {
179
+ readonly coordinate: string;
180
+ readonly kind:
181
+ | "invalid-admission"
182
+ | "owner-unknown"
183
+ | "resource-active"
184
+ | "cleanup-unsafe"
185
+ | "recovery-active"
186
+ | "recovery-conflict";
187
+ };
188
+
189
+ export type WebSessionCleanupAdmissionRecoveryReport = {
190
+ readonly scanned: number;
191
+ readonly repaired: number;
192
+ readonly active: number;
193
+ readonly retained: number;
194
+ readonly invalid: number;
195
+ readonly issues: readonly WebSessionCleanupAdmissionRecoveryIssue[];
196
+ };
197
+
198
+ function record(value: unknown, label: string): JsonRecord {
199
+ if (
200
+ typeof value !== "object"
201
+ || value === null
202
+ || Array.isArray(value)
203
+ || nodeTypes.isProxy(value)
204
+ ) {
205
+ throw new Error(`${label} must be an object`);
206
+ }
207
+ const prototype: unknown = Object.getPrototypeOf(value) as unknown;
208
+ if (prototype !== Object.prototype && prototype !== null) {
209
+ throw new Error(`${label} must be a plain object`);
210
+ }
211
+ const descriptors = Object.getOwnPropertyDescriptors(value);
212
+ const result: JsonRecord = {};
213
+ for (const key of Reflect.ownKeys(descriptors)) {
214
+ if (typeof key !== "string") {
215
+ throw new Error(`${label} has unsupported symbol fields`);
216
+ }
217
+ const descriptor = descriptors[key];
218
+ if (
219
+ descriptor === undefined
220
+ || !descriptor.enumerable
221
+ || !("value" in descriptor)
222
+ ) {
223
+ throw new Error(`${label} has unsupported accessor fields`);
224
+ }
225
+ result[key] = descriptor.value as unknown;
226
+ }
227
+ return result;
228
+ }
229
+
230
+ function exactKeys(
231
+ value: JsonRecord,
232
+ expectedKeys: readonly string[],
233
+ label: string,
234
+ ): void {
235
+ const actual = Object.keys(value).sort();
236
+ const expected = [...expectedKeys].sort();
237
+ if (
238
+ actual.length !== expected.length
239
+ || actual.some((key, index) => key !== expected[index])
240
+ ) {
241
+ throw new Error(`${label} has unsupported fields`);
242
+ }
243
+ }
244
+
245
+ function digest(value: unknown, label: string): string {
246
+ if (typeof value !== "string" || !digestPattern.test(value)) {
247
+ throw new Error(`${label} is malformed`);
248
+ }
249
+ return value;
250
+ }
251
+
252
+ function identifier(
253
+ value: unknown,
254
+ label: string,
255
+ pattern = idPattern,
256
+ ): string {
257
+ if (typeof value !== "string" || !pattern.test(value)) {
258
+ throw new Error(`${label} is malformed`);
259
+ }
260
+ return value;
261
+ }
262
+
263
+ function uuid(value: unknown, label: string): string {
264
+ if (typeof value !== "string" || !uuidPattern.test(value)) {
265
+ throw new Error(`${label} is malformed`);
266
+ }
267
+ return value;
268
+ }
269
+
270
+ function timestamp(value: unknown, label: string): string {
271
+ if (
272
+ typeof value !== "string"
273
+ || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
274
+ || new Date(value).toISOString() !== value
275
+ ) {
276
+ throw new Error(`${label} is malformed`);
277
+ }
278
+ return value;
279
+ }
280
+
281
+ function parseOwner(
282
+ value: unknown,
283
+ ): WebSessionCleanupAdmissionClaim["owner"] {
284
+ const owner = record(value, "web-session cleanup admission owner");
285
+ exactKeys(
286
+ owner,
287
+ ["pid", "token", "bootId", "processStartId"],
288
+ "web-session cleanup admission owner",
289
+ );
290
+ if (
291
+ typeof owner.pid !== "number"
292
+ || !Number.isSafeInteger(owner.pid)
293
+ || owner.pid < 1
294
+ ) {
295
+ throw new Error("web-session cleanup admission owner PID is malformed");
296
+ }
297
+ return Object.freeze({
298
+ pid: owner.pid,
299
+ token: uuid(
300
+ owner.token,
301
+ "web-session cleanup admission owner token",
302
+ ),
303
+ bootId: digest(
304
+ owner.bootId,
305
+ "web-session cleanup admission owner boot identity",
306
+ ),
307
+ processStartId: digest(
308
+ owner.processStartId,
309
+ "web-session cleanup admission owner process identity",
310
+ ),
311
+ });
312
+ }
313
+
314
+ function parseRecovery(
315
+ value: unknown,
316
+ ): WebSessionCleanupAdmissionRecovery {
317
+ const recovery = record(
318
+ value,
319
+ "web-session cleanup admission recovery",
320
+ );
321
+ if (recovery.status === "idle") {
322
+ exactKeys(
323
+ recovery,
324
+ ["status"],
325
+ "web-session cleanup admission recovery",
326
+ );
327
+ return Object.freeze({ status: "idle" });
328
+ }
329
+ exactKeys(
330
+ recovery,
331
+ ["status", "owner", "acquiredAt"],
332
+ "web-session cleanup admission recovery",
333
+ );
334
+ if (recovery.status !== "active") {
335
+ throw new Error("web-session cleanup admission recovery is malformed");
336
+ }
337
+ return Object.freeze({
338
+ status: "active",
339
+ owner: parseOwner(recovery.owner),
340
+ acquiredAt: timestamp(
341
+ recovery.acquiredAt,
342
+ "web-session cleanup admission recovery acquisition time",
343
+ ),
344
+ });
345
+ }
346
+
347
+ function parseContainment(
348
+ value: unknown,
349
+ ): WebSessionCleanupAdmissionContainment {
350
+ const containment = record(
351
+ value,
352
+ "web-session cleanup admission containment",
353
+ );
354
+ exactKeys(
355
+ containment,
356
+ ["status"],
357
+ "web-session cleanup admission containment",
358
+ );
359
+ if (
360
+ containment.status !== "parent-owned"
361
+ && containment.status !== "resource-active"
362
+ && containment.status !== "cleanup-complete"
363
+ && containment.status !== "cleanup-unsafe"
364
+ ) {
365
+ throw new Error("web-session cleanup admission containment is malformed");
366
+ }
367
+ return Object.freeze({ status: containment.status });
368
+ }
369
+
370
+ function parseCleanupResourceIdentity(
371
+ value: unknown,
372
+ ): ProviderPluginCleanupResourceIdentity {
373
+ const kind = typeof value === "object"
374
+ && value !== null
375
+ && !Array.isArray(value)
376
+ ? Object.getOwnPropertyDescriptor(value, "kind")
377
+ : undefined;
378
+ if (
379
+ kind === undefined
380
+ || !kind.enumerable
381
+ || !("value" in kind)
382
+ ) {
383
+ throw new Error("provider cleanup resource identity kind is malformed");
384
+ }
385
+ return kind.value === "local-cli-private-root-v1"
386
+ ? parseLocalCliCleanupResourceIdentityV1(value)
387
+ : parseBrowserCleanupResourceIdentity(value);
388
+ }
389
+
390
+ function parseCleanupResources(
391
+ value: unknown,
392
+ ): readonly WebSessionCleanupAdmissionResource[] {
393
+ if (!Array.isArray(value) || value.length > 8) {
394
+ throw new Error("web-session cleanup resource collection is malformed");
395
+ }
396
+ const resources: WebSessionCleanupAdmissionResource[] = [];
397
+ const resourceIds = new Set<string>();
398
+ for (const valueEntry of value as readonly unknown[]) {
399
+ const entry = record(valueEntry, "web-session cleanup resource");
400
+ if (entry.status === "unpublished") {
401
+ exactKeys(
402
+ entry,
403
+ ["resourceId", "status"],
404
+ "web-session cleanup resource",
405
+ );
406
+ const resourceId = uuid(
407
+ entry.resourceId,
408
+ "web-session cleanup resource ID",
409
+ );
410
+ if (resourceIds.has(resourceId)) {
411
+ throw new Error("web-session cleanup resource ID is duplicated");
412
+ }
413
+ resourceIds.add(resourceId);
414
+ resources.push(Object.freeze({
415
+ resourceId,
416
+ status: "unpublished",
417
+ }));
418
+ continue;
419
+ }
420
+ if (
421
+ entry.status !== "active"
422
+ && entry.status !== "browser-closed-artifacts"
423
+ && entry.status !== "browser-quiescent-artifacts"
424
+ ) {
425
+ throw new Error("web-session cleanup resource status is malformed");
426
+ }
427
+ if (entry.status === "browser-quiescent-artifacts") {
428
+ exactKeys(
429
+ entry,
430
+ ["resourceId", "status", "identity", "removedRoots"],
431
+ "web-session cleanup resource",
432
+ );
433
+ const resourceId = uuid(
434
+ entry.resourceId,
435
+ "web-session cleanup resource ID",
436
+ );
437
+ if (resourceIds.has(resourceId)) {
438
+ throw new Error("web-session cleanup resource ID is duplicated");
439
+ }
440
+ resourceIds.add(resourceId);
441
+ const identity = parseBrowserCleanupResourceIdentity(entry.identity);
442
+ if (
443
+ identity.kind !== "agent-browser-session-v2"
444
+ || !Array.isArray(entry.removedRoots)
445
+ || entry.removedRoots.length > 2
446
+ || entry.removedRoots.some((root, index) =>
447
+ root !== (["artifacts", "socket"] as const)[index]
448
+ )
449
+ ) {
450
+ throw new Error(
451
+ "web-session cleanup browser root-removal journal is malformed",
452
+ );
453
+ }
454
+ resources.push(Object.freeze({
455
+ resourceId,
456
+ status: "browser-quiescent-artifacts" as const,
457
+ identity,
458
+ removedRoots: Object.freeze(
459
+ [...entry.removedRoots] as BrowserCleanupRootName[],
460
+ ),
461
+ }));
462
+ continue;
463
+ }
464
+ exactKeys(
465
+ entry,
466
+ ["resourceId", "status", "identity"],
467
+ "web-session cleanup resource",
468
+ );
469
+ const resourceId = uuid(
470
+ entry.resourceId,
471
+ "web-session cleanup resource ID",
472
+ );
473
+ if (resourceIds.has(resourceId)) {
474
+ throw new Error("web-session cleanup resource ID is duplicated");
475
+ }
476
+ resourceIds.add(resourceId);
477
+ resources.push(entry.status === "browser-closed-artifacts"
478
+ ? Object.freeze({
479
+ resourceId,
480
+ status: "browser-closed-artifacts" as const,
481
+ identity: parseBrowserCleanupResourceIdentity(entry.identity),
482
+ })
483
+ : Object.freeze({
484
+ resourceId,
485
+ status: "active" as const,
486
+ identity: parseCleanupResourceIdentity(entry.identity),
487
+ }));
488
+ }
489
+ return Object.freeze(resources);
490
+ }
491
+
492
+ export function webSessionCleanupRealmKey(
493
+ surfaceIdValue: string,
494
+ authIdValue: string,
495
+ ): string {
496
+ const surfaceId = identifier(
497
+ surfaceIdValue,
498
+ "web-session cleanup surface ID",
499
+ );
500
+ const authId = identifier(
501
+ authIdValue,
502
+ "web-session cleanup auth ID",
503
+ authIdPattern,
504
+ );
505
+ return sha256(
506
+ `io-web-session-cleanup-realm-v1\0${canonicalJson({
507
+ surfaceId,
508
+ authId,
509
+ })}`,
510
+ );
511
+ }
512
+
513
+ export function parseWebSessionCleanupAdmissionClaim(
514
+ value: unknown,
515
+ ): WebSessionCleanupAdmissionClaim {
516
+ const claim = record(value, "web-session cleanup admission");
517
+ if (
518
+ claim.schemaVersion !== LEGACY_ADMISSION_SCHEMA_VERSION
519
+ && claim.schemaVersion !== ADMISSION_SCHEMA_VERSION
520
+ ) {
521
+ throw new Error("web-session cleanup admission version is unsupported");
522
+ }
523
+ exactKeys(
524
+ claim,
525
+ [
526
+ "schemaVersion",
527
+ "realmKey",
528
+ "runId",
529
+ "pluginId",
530
+ "pluginVersion",
531
+ "pluginImplementationHash",
532
+ "adapterId",
533
+ "adapterHash",
534
+ "surfaceId",
535
+ "authId",
536
+ "authHash",
537
+ "owner",
538
+ "acquiredAt",
539
+ "containment",
540
+ "resources",
541
+ ...(claim.schemaVersion === ADMISSION_SCHEMA_VERSION
542
+ ? ["recovery"]
543
+ : []),
544
+ ...(claim.transport === undefined ? [] : ["transport"]),
545
+ ...(claim.executionIdentityHash === undefined
546
+ ? []
547
+ : ["executionIdentityHash"]),
548
+ ],
549
+ "web-session cleanup admission",
550
+ );
551
+ if (
552
+ (claim.transport === undefined)
553
+ !== (claim.executionIdentityHash === undefined)
554
+ || (
555
+ claim.transport !== undefined
556
+ && claim.transport !== "web-session-api"
557
+ && claim.transport !== "local-cli"
558
+ )
559
+ ) {
560
+ throw new Error("web-session cleanup execution identity is malformed");
561
+ }
562
+ const pluginVersion = claim.pluginVersion;
563
+ if (
564
+ typeof pluginVersion !== "string"
565
+ || pluginVersion.length < 1
566
+ || pluginVersion.length > 128
567
+ || /[\0\r\n]/u.test(pluginVersion)
568
+ ) {
569
+ throw new Error("web-session cleanup plugin version is malformed");
570
+ }
571
+ const parsedFields: WebSessionCleanupAdmissionClaimFields = Object.freeze({
572
+ realmKey: digest(
573
+ claim.realmKey,
574
+ "web-session cleanup admission realm key",
575
+ ),
576
+ runId: uuid(claim.runId, "web-session cleanup admission run ID"),
577
+ pluginId: identifier(
578
+ claim.pluginId,
579
+ "web-session cleanup admission plugin ID",
580
+ ),
581
+ pluginVersion,
582
+ pluginImplementationHash: digest(
583
+ claim.pluginImplementationHash,
584
+ "web-session cleanup admission implementation hash",
585
+ ),
586
+ adapterId: identifier(
587
+ claim.adapterId,
588
+ "web-session cleanup admission adapter ID",
589
+ authIdPattern,
590
+ ),
591
+ adapterHash: digest(
592
+ claim.adapterHash,
593
+ "web-session cleanup admission adapter hash",
594
+ ),
595
+ surfaceId: identifier(
596
+ claim.surfaceId,
597
+ "web-session cleanup admission surface ID",
598
+ ),
599
+ authId: identifier(
600
+ claim.authId,
601
+ "web-session cleanup admission auth ID",
602
+ authIdPattern,
603
+ ),
604
+ authHash: digest(
605
+ claim.authHash,
606
+ "web-session cleanup admission auth hash",
607
+ ),
608
+ ...(claim.transport === undefined
609
+ ? {}
610
+ : {
611
+ transport: claim.transport,
612
+ executionIdentityHash: digest(
613
+ claim.executionIdentityHash,
614
+ "web-session cleanup admission execution identity hash",
615
+ ),
616
+ }),
617
+ owner: parseOwner(claim.owner),
618
+ acquiredAt: timestamp(
619
+ claim.acquiredAt,
620
+ "web-session cleanup admission acquisition time",
621
+ ),
622
+ containment: parseContainment(claim.containment),
623
+ resources: parseCleanupResources(claim.resources),
624
+ });
625
+ const parsed: WebSessionCleanupAdmissionClaim = claim.schemaVersion
626
+ === LEGACY_ADMISSION_SCHEMA_VERSION
627
+ ? Object.freeze({
628
+ schemaVersion: LEGACY_ADMISSION_SCHEMA_VERSION,
629
+ ...parsedFields,
630
+ })
631
+ : Object.freeze({
632
+ schemaVersion: ADMISSION_SCHEMA_VERSION,
633
+ ...parsedFields,
634
+ recovery: parseRecovery(claim.recovery),
635
+ });
636
+ if (
637
+ parsed.schemaVersion === LEGACY_ADMISSION_SCHEMA_VERSION
638
+ && parsed.resources.some((resource) =>
639
+ resource.status === "browser-quiescent-artifacts"
640
+ || (
641
+ resource.status !== "unpublished"
642
+ && resource.identity.kind === "agent-browser-session-v2"
643
+ )
644
+ )
645
+ ) {
646
+ throw new Error(
647
+ "legacy web-session cleanup admission contains a future resource state",
648
+ );
649
+ }
650
+ if (
651
+ parsed.realmKey
652
+ !== webSessionCleanupRealmKey(parsed.surfaceId, parsed.authId)
653
+ ) {
654
+ throw new Error(
655
+ "web-session cleanup admission realm does not match its surface and auth",
656
+ );
657
+ }
658
+ for (const resource of parsed.resources) {
659
+ if (
660
+ resource.status !== "unpublished"
661
+ && resource.identity.kind !== "local-cli-private-root-v1"
662
+ && !resource.identity.session.startsWith(
663
+ `io-${parsed.owner.pid}-`,
664
+ )
665
+ ) {
666
+ throw new Error(
667
+ "web-session cleanup browser resource does not match its owning process",
668
+ );
669
+ }
670
+ if (
671
+ resource.status !== "unpublished"
672
+ && (
673
+ (parsed.transport === "local-cli")
674
+ !== (resource.identity.kind === "local-cli-private-root-v1")
675
+ )
676
+ ) {
677
+ throw new Error(
678
+ "provider cleanup resource does not match its admitted transport",
679
+ );
680
+ }
681
+ }
682
+ if (
683
+ parsed.containment.status === "parent-owned"
684
+ && parsed.resources.length !== 0
685
+ ) {
686
+ throw new Error(
687
+ "parent-owned web-session cleanup admission cannot own resources",
688
+ );
689
+ }
690
+ if (
691
+ (
692
+ parsed.containment.status === "resource-active"
693
+ || parsed.containment.status === "cleanup-unsafe"
694
+ )
695
+ && parsed.resources.length === 0
696
+ ) {
697
+ throw new Error(
698
+ "resource-owning web-session cleanup admission omitted its resources",
699
+ );
700
+ }
701
+ return parsed;
702
+ }
703
+
704
+ function directory(environment: Environment): string {
705
+ return join(
706
+ ghostgetStateHome(environment),
707
+ WEB_SESSION_CLEANUP_ADMISSION_STATE_DIRECTORY,
708
+ );
709
+ }
710
+
711
+ function pathFor(realmKeyValue: string, environment: Environment): string {
712
+ return join(
713
+ directory(environment),
714
+ `${digest(
715
+ realmKeyValue,
716
+ "web-session cleanup admission realm key",
717
+ )}.json`,
718
+ );
719
+ }
720
+
721
+ function claimSnapshot(
722
+ claimValue: WebSessionCleanupAdmissionClaim,
723
+ ): WebSessionCleanupAdmissionSnapshot {
724
+ const claim = parseWebSessionCleanupAdmissionClaim(claimValue);
725
+ const content = `${canonicalJson(claim)}\n`;
726
+ if (Buffer.byteLength(content, "utf8") > MAX_ADMISSION_BYTES) {
727
+ throw new Error("web-session cleanup admission exceeds its byte bound");
728
+ }
729
+ return Object.freeze({
730
+ claim,
731
+ contentSha256: sha256(content),
732
+ });
733
+ }
734
+
735
+ function isStorageHelperArtifact(
736
+ entry: {
737
+ readonly name: string;
738
+ readonly kind: "file" | "directory" | "symbolic-link" | "other";
739
+ },
740
+ ): boolean {
741
+ return entry.kind === "file"
742
+ && storageHelperArtifactPatterns.some((pattern) =>
743
+ pattern.test(entry.name)
744
+ );
745
+ }
746
+
747
+ export function listWebSessionCleanupAdmissions(
748
+ environment: Environment = process.env,
749
+ ): readonly WebSessionCleanupAdmissionListEntry[] {
750
+ const admissionDirectory = directory(environment);
751
+ const directorySnapshot = snapshotPrivateStateDirectory(
752
+ admissionDirectory,
753
+ environment,
754
+ );
755
+ if (directorySnapshot.identity === null) return Object.freeze([]);
756
+ const admissionEntries = directorySnapshot.entries.filter((entry) =>
757
+ !isStorageHelperArtifact(entry)
758
+ );
759
+ const claimCandidates = admissionEntries.filter((entry) =>
760
+ entry.kind === "file" && claimNamePattern.test(entry.name)
761
+ );
762
+ const files = readPrivateStateFilesBatched(
763
+ admissionDirectory,
764
+ claimCandidates.map((entry) => entry.name),
765
+ {
766
+ maximumBytesPerFile: MAX_ADMISSION_BYTES,
767
+ environment,
768
+ expectedDirectoryIdentity: directorySnapshot.identity,
769
+ },
770
+ );
771
+ const byName = new Map(files.map((file) => [file.name, file] as const));
772
+ return Object.freeze(admissionEntries.map((entry) => {
773
+ const match = claimNamePattern.exec(entry.name);
774
+ const coordinate = match?.[1] ?? sha256(entry.name);
775
+ if (
776
+ entry.kind !== "file"
777
+ || match === null
778
+ || match[1] === undefined
779
+ ) {
780
+ return { coordinate, invalid: true as const };
781
+ }
782
+ const file = byName.get(entry.name);
783
+ if (file?.status !== "present") {
784
+ return { coordinate, invalid: true as const };
785
+ }
786
+ try {
787
+ const claim = parseWebSessionCleanupAdmissionClaim(
788
+ JSON.parse(file.content) as unknown,
789
+ );
790
+ if (
791
+ claim.realmKey !== match[1]
792
+ || file.content !== `${canonicalJson(claim)}\n`
793
+ ) {
794
+ return { coordinate, invalid: true as const };
795
+ }
796
+ return Object.freeze({
797
+ claim,
798
+ contentSha256: sha256(file.content),
799
+ });
800
+ } catch {
801
+ return { coordinate, invalid: true as const };
802
+ }
803
+ }));
804
+ }
805
+
806
+ function readWebSessionCleanupAdmission(
807
+ realmKeyValue: string,
808
+ environment: Environment,
809
+ ): WebSessionCleanupAdmissionSnapshot | null {
810
+ const realmKey = digest(
811
+ realmKeyValue,
812
+ "web-session cleanup admission realm key",
813
+ );
814
+ const path = pathFor(realmKey, environment);
815
+ let content: string | null;
816
+ try {
817
+ content = readPrivateStateFileIfPresent(
818
+ path,
819
+ MAX_ADMISSION_BYTES,
820
+ "web-session cleanup admission",
821
+ environment,
822
+ );
823
+ } catch (error) {
824
+ throw new Error(
825
+ "requested web-session cleanup admission is unreadable or unsafe; run ghostget doctor before continuing",
826
+ { cause: error },
827
+ );
828
+ }
829
+ if (content === null) return null;
830
+ try {
831
+ const claim = parseWebSessionCleanupAdmissionClaim(
832
+ JSON.parse(content) as unknown,
833
+ );
834
+ if (
835
+ claim.realmKey !== realmKey
836
+ || content !== `${canonicalJson(claim)}\n`
837
+ ) {
838
+ throw new Error(
839
+ "web-session cleanup admission does not match its canonical realm",
840
+ );
841
+ }
842
+ return Object.freeze({
843
+ claim,
844
+ contentSha256: sha256(content),
845
+ });
846
+ } catch (error) {
847
+ throw new Error(
848
+ "requested web-session cleanup admission is invalid; run ghostget doctor before continuing",
849
+ { cause: error },
850
+ );
851
+ }
852
+ }
853
+
854
+ function replaceClaim(
855
+ current: WebSessionCleanupAdmissionSnapshot,
856
+ claim: WebSessionCleanupAdmissionClaim,
857
+ environment: Environment,
858
+ ): WebSessionCleanupAdmissionSnapshot {
859
+ const checked = claimSnapshot(current.claim);
860
+ if (checked.contentSha256 !== current.contentSha256) {
861
+ throw new Error(
862
+ "web-session cleanup admission snapshot is not content-bound",
863
+ );
864
+ }
865
+ const next = claimSnapshot(claim);
866
+ if (
867
+ next.claim.realmKey !== checked.claim.realmKey
868
+ || next.claim.runId !== checked.claim.runId
869
+ || next.claim.owner.token !== checked.claim.owner.token
870
+ ) {
871
+ throw new Error(
872
+ "web-session cleanup admission replacement changed its identity",
873
+ );
874
+ }
875
+ if (!writePrivateJsonIfUnchanged(
876
+ pathFor(checked.claim.realmKey, environment),
877
+ next.claim,
878
+ { expectedCurrentContentSha256: checked.contentSha256 },
879
+ )) {
880
+ throw new Error(
881
+ "web-session cleanup admission changed before containment update",
882
+ );
883
+ }
884
+ return next;
885
+ }
886
+
887
+ /**
888
+ * A state-file CAS may commit and then report failure. Re-read the exact
889
+ * desired snapshot before propagating the error so callers never mistake a
890
+ * durable publication for an unpublished resource.
891
+ */
892
+ function replaceClaimOrAdoptCommitted(
893
+ current: WebSessionCleanupAdmissionSnapshot,
894
+ claim: WebSessionCleanupAdmissionClaim,
895
+ environment: Environment,
896
+ afterCommitForTest?: () => void,
897
+ ): WebSessionCleanupAdmissionSnapshot {
898
+ const desired = claimSnapshot(claim);
899
+ try {
900
+ const committed = replaceClaim(current, desired.claim, environment);
901
+ afterCommitForTest?.();
902
+ return committed;
903
+ } catch (error) {
904
+ let observed: WebSessionCleanupAdmissionSnapshot | null;
905
+ try {
906
+ observed = readWebSessionCleanupAdmission(
907
+ desired.claim.realmKey,
908
+ environment,
909
+ );
910
+ } catch (reconciliationError) {
911
+ throw new AggregateError(
912
+ [error, reconciliationError],
913
+ "web-session cleanup publication could not be reconciled",
914
+ );
915
+ }
916
+ if (observed?.contentSha256 === desired.contentSha256) {
917
+ return observed;
918
+ }
919
+ throw error;
920
+ }
921
+ }
922
+
923
+ function replaceContainment(
924
+ current: WebSessionCleanupAdmissionSnapshot,
925
+ containment: WebSessionCleanupAdmissionContainment,
926
+ environment: Environment,
927
+ ): WebSessionCleanupAdmissionSnapshot {
928
+ return replaceClaim(
929
+ current,
930
+ Object.freeze({
931
+ ...current.claim,
932
+ containment,
933
+ }),
934
+ environment,
935
+ );
936
+ }
937
+
938
+ export type WebSessionCleanupAdmissionController = {
939
+ readonly current: WebSessionCleanupAdmissionSnapshot;
940
+ readonly registerCleanupBarrier: WebSessionCleanupBarrierRegistrar;
941
+ readonly closeRegistration: () => void;
942
+ readonly cleanupComplete: () => void;
943
+ readonly cleanupUnsafe: () => void;
944
+ readonly release: () => void;
945
+ readonly barriers: readonly Promise<void>[];
946
+ };
947
+
948
+ type TrackedCleanupBarrier = {
949
+ promise: Promise<void>;
950
+ status: "pending" | "fulfilled" | "rejected";
951
+ readonly resourceId: string;
952
+ reason?: unknown;
953
+ };
954
+
955
+ type WebSessionCleanupAdmissionControllerDependencies = {
956
+ /** Test-only seam that throws after one resource-state CAS commits. */
957
+ readonly afterResourceStateCommitForTest?: () => void;
958
+ };
959
+
960
+ function recoverableBrowserResource(
961
+ reason: unknown,
962
+ resource: WebSessionCleanupAdmissionResource,
963
+ ): WebSessionCleanupAdmissionResource | null {
964
+ if (
965
+ !(reason instanceof PreservedBrowserArtifactsError)
966
+ || reason.cleanupEvidence?.kind
967
+ !== "agent-browser-closed-artifacts-v1"
968
+ ) {
969
+ return null;
970
+ }
971
+ let identity: BrowserCleanupResourceIdentity;
972
+ try {
973
+ identity = parseBrowserCleanupResourceIdentity(
974
+ reason.cleanupEvidence.resource,
975
+ );
976
+ } catch {
977
+ return null;
978
+ }
979
+ if (
980
+ resource.status !== "unpublished"
981
+ && (
982
+ resource.status !== "active"
983
+ || resource.identity.kind === "local-cli-private-root-v1"
984
+ || canonicalJson(identity) !== canonicalJson(resource.identity)
985
+ )
986
+ ) return null;
987
+ return Object.freeze({
988
+ resourceId: resource.resourceId,
989
+ status: "browser-closed-artifacts",
990
+ identity,
991
+ });
992
+ }
993
+
994
+ function controller(
995
+ initial: WebSessionCleanupAdmissionSnapshot,
996
+ environment: Environment,
997
+ dependencies: WebSessionCleanupAdmissionControllerDependencies = {},
998
+ ): WebSessionCleanupAdmissionController {
999
+ let current = claimSnapshot(initial.claim);
1000
+ if (current.contentSha256 !== initial.contentSha256) {
1001
+ throw new Error(
1002
+ "web-session cleanup admission snapshot is not content-bound",
1003
+ );
1004
+ }
1005
+ let accepting = true;
1006
+ let released = false;
1007
+ const barriers: TrackedCleanupBarrier[] = [];
1008
+ const result: WebSessionCleanupAdmissionController = {
1009
+ get current() {
1010
+ return current;
1011
+ },
1012
+ get barriers() {
1013
+ return Object.freeze(barriers.map((barrier) => barrier.promise));
1014
+ },
1015
+ registerCleanupBarrier: (barrier) => {
1016
+ if (!accepting) {
1017
+ throw new Error(
1018
+ "web-session cleanup admission registration is closed",
1019
+ );
1020
+ }
1021
+ if (
1022
+ current.claim.containment.status !== "parent-owned"
1023
+ && current.claim.containment.status !== "resource-active"
1024
+ ) {
1025
+ throw new Error(
1026
+ "web-session cleanup admission is not accepting resources",
1027
+ );
1028
+ }
1029
+ if (current.claim.resources.length >= 8) {
1030
+ throw new Error(
1031
+ "web-session cleanup admission resource count exceeds its bound",
1032
+ );
1033
+ }
1034
+ const resourceId = randomUUID();
1035
+ current = replaceClaim(
1036
+ current,
1037
+ Object.freeze({
1038
+ ...current.claim,
1039
+ containment: Object.freeze({ status: "resource-active" }),
1040
+ resources: Object.freeze([
1041
+ ...current.claim.resources,
1042
+ Object.freeze({
1043
+ resourceId,
1044
+ status: "unpublished" as const,
1045
+ }),
1046
+ ]),
1047
+ }),
1048
+ environment,
1049
+ );
1050
+ const tracked: TrackedCleanupBarrier = {
1051
+ status: "pending",
1052
+ promise: Promise.resolve(),
1053
+ resourceId,
1054
+ };
1055
+ tracked.promise = Promise.resolve(barrier).then(
1056
+ () => {
1057
+ tracked.status = "fulfilled";
1058
+ },
1059
+ (cause: unknown) => {
1060
+ tracked.status = "rejected";
1061
+ tracked.reason = cause;
1062
+ throw cause;
1063
+ },
1064
+ );
1065
+ void tracked.promise.catch(() => undefined);
1066
+ barriers.push(tracked);
1067
+ const publishResource = (
1068
+ resourceValue: ProviderPluginCleanupResourceIdentity,
1069
+ ): void => {
1070
+ const resourceIdentity =
1071
+ parseCleanupResourceIdentity(resourceValue);
1072
+ const resources = current.claim.resources.map((resource) => {
1073
+ if (resource.resourceId !== resourceId) return resource;
1074
+ if (resource.status === "unpublished") {
1075
+ return Object.freeze({
1076
+ resourceId,
1077
+ status: "active" as const,
1078
+ identity: resourceIdentity,
1079
+ });
1080
+ }
1081
+ if (
1082
+ resource.status === "active"
1083
+ && canonicalJson(resource.identity)
1084
+ === canonicalJson(resourceIdentity)
1085
+ ) {
1086
+ return resource;
1087
+ }
1088
+ if (
1089
+ resource.status === "active"
1090
+ && resource.identity.kind === "local-cli-private-root-v1"
1091
+ && resourceIdentity.kind === "local-cli-private-root-v1"
1092
+ && localCliCleanupResourceExtends(
1093
+ resource.identity,
1094
+ resourceIdentity,
1095
+ )
1096
+ ) {
1097
+ return Object.freeze({
1098
+ resourceId,
1099
+ status: "active" as const,
1100
+ identity: resourceIdentity,
1101
+ });
1102
+ }
1103
+ if (
1104
+ resource.status === "active"
1105
+ && resource.identity.kind !== "local-cli-private-root-v1"
1106
+ && resourceIdentity.kind !== "local-cli-private-root-v1"
1107
+ && browserCleanupResourceExtends(
1108
+ resource.identity,
1109
+ resourceIdentity,
1110
+ )
1111
+ ) {
1112
+ return Object.freeze({
1113
+ resourceId,
1114
+ status: "active" as const,
1115
+ identity: resourceIdentity,
1116
+ });
1117
+ }
1118
+ throw new Error(
1119
+ "web-session cleanup resource identity changed after publication",
1120
+ );
1121
+ });
1122
+ current = replaceClaimOrAdoptCommitted(
1123
+ current,
1124
+ Object.freeze({
1125
+ ...current.claim,
1126
+ resources: Object.freeze(resources),
1127
+ }),
1128
+ environment,
1129
+ dependencies.afterResourceStateCommitForTest,
1130
+ );
1131
+ };
1132
+ const exactPublishedBrowserIdentity = (
1133
+ resourceValue: BrowserCleanupResourceIdentity,
1134
+ status: "active" | "browser-quiescent-artifacts",
1135
+ ): BrowserCleanupResourceIdentityV2 => {
1136
+ const identity = parseBrowserCleanupResourceIdentity(resourceValue);
1137
+ const selected = current.claim.resources.find((resource) =>
1138
+ resource.resourceId === resourceId
1139
+ );
1140
+ if (
1141
+ identity.kind !== "agent-browser-session-v2"
1142
+ || selected?.status !== status
1143
+ || canonicalJson(selected.identity) !== canonicalJson(identity)
1144
+ ) {
1145
+ throw new Error(
1146
+ "web-session cleanup browser journal changed resource identity",
1147
+ );
1148
+ }
1149
+ return identity;
1150
+ };
1151
+ return Object.assign(publishResource, {
1152
+ markBrowserCleanupQuiescent: (
1153
+ resourceValue: BrowserCleanupResourceIdentity,
1154
+ ): void => {
1155
+ const identity = exactPublishedBrowserIdentity(
1156
+ resourceValue,
1157
+ "active",
1158
+ );
1159
+ if (
1160
+ browserCleanupResourceRootStatus(identity, "artifacts") !== "match"
1161
+ || browserCleanupResourceRootStatus(identity, "socket") !== "match"
1162
+ ) {
1163
+ throw new Error(
1164
+ "web-session cleanup browser roots changed before quiescence",
1165
+ );
1166
+ }
1167
+ current = replaceBrowserResource(
1168
+ current,
1169
+ resourceId,
1170
+ identity,
1171
+ "browser-quiescent-artifacts",
1172
+ environment,
1173
+ dependencies.afterResourceStateCommitForTest,
1174
+ );
1175
+ },
1176
+ markBrowserCleanupRootRemoved: (
1177
+ resourceValue: BrowserCleanupResourceIdentity,
1178
+ root: BrowserCleanupRootName,
1179
+ ): void => {
1180
+ const identity = exactPublishedBrowserIdentity(
1181
+ resourceValue,
1182
+ "browser-quiescent-artifacts",
1183
+ );
1184
+ const selected = current.claim.resources.find((resource) =>
1185
+ resource.resourceId === resourceId
1186
+ );
1187
+ const expectedRoot = selected?.status === "browser-quiescent-artifacts"
1188
+ ? (["artifacts", "socket"] as const)[selected.removedRoots.length]
1189
+ : undefined;
1190
+ const companionRoot = root === "artifacts" ? "socket" : "artifacts";
1191
+ const companionStatus = root === "artifacts" ? "match" : "absent";
1192
+ if (
1193
+ expectedRoot !== root
1194
+ || browserCleanupResourceRootStatus(identity, root) !== "absent"
1195
+ || browserCleanupResourceRootStatus(identity, companionRoot)
1196
+ !== companionStatus
1197
+ ) {
1198
+ throw new Error(
1199
+ "web-session cleanup browser root removal is not exact",
1200
+ );
1201
+ }
1202
+ current = journalBrowserRootRemoved(
1203
+ current,
1204
+ resourceId,
1205
+ root,
1206
+ environment,
1207
+ dependencies.afterResourceStateCommitForTest,
1208
+ );
1209
+ },
1210
+ });
1211
+ },
1212
+ closeRegistration: () => {
1213
+ accepting = false;
1214
+ },
1215
+ cleanupComplete: () => {
1216
+ if (current.claim.containment.status === "cleanup-complete") return;
1217
+ if (accepting) {
1218
+ throw new Error(
1219
+ "web-session cleanup admission registration must close before cleanup completion",
1220
+ );
1221
+ }
1222
+ if (current.claim.containment.status === "cleanup-unsafe") {
1223
+ throw new Error(
1224
+ "web-session cleanup admission cannot complete after becoming unsafe",
1225
+ );
1226
+ }
1227
+ if (barriers.some((barrier) => barrier.status === "pending")) {
1228
+ throw new Error(
1229
+ "web-session cleanup admission barriers have not all settled",
1230
+ );
1231
+ }
1232
+ if (barriers.some((barrier) => barrier.status === "rejected")) {
1233
+ throw new Error(
1234
+ "web-session cleanup admission cannot complete after a rejected barrier",
1235
+ );
1236
+ }
1237
+ current = replaceContainment(
1238
+ current,
1239
+ Object.freeze({ status: "cleanup-complete" }),
1240
+ environment,
1241
+ );
1242
+ },
1243
+ cleanupUnsafe: () => {
1244
+ if (current.claim.containment.status === "cleanup-unsafe") return;
1245
+ if (accepting) {
1246
+ throw new Error(
1247
+ "web-session cleanup admission registration must close before cleanup is marked unsafe",
1248
+ );
1249
+ }
1250
+ if (current.claim.containment.status === "cleanup-complete") {
1251
+ throw new Error(
1252
+ "web-session cleanup admission became unsafe after completion",
1253
+ );
1254
+ }
1255
+ const byResourceId = new Map(
1256
+ barriers.map((barrier) => [barrier.resourceId, barrier] as const),
1257
+ );
1258
+ const resources = current.claim.resources.flatMap((resource) => {
1259
+ const tracked = byResourceId.get(resource.resourceId);
1260
+ if (tracked?.status === "fulfilled") return [];
1261
+ if (tracked?.status === "rejected") {
1262
+ return [
1263
+ recoverableBrowserResource(tracked.reason, resource)
1264
+ ?? resource,
1265
+ ];
1266
+ }
1267
+ return [resource];
1268
+ });
1269
+ current = replaceClaim(
1270
+ current,
1271
+ Object.freeze({
1272
+ ...current.claim,
1273
+ containment: Object.freeze({ status: "cleanup-unsafe" }),
1274
+ resources: Object.freeze(resources),
1275
+ }),
1276
+ environment,
1277
+ );
1278
+ },
1279
+ release: () => {
1280
+ if (released) return;
1281
+ if (current.claim.containment.status !== "cleanup-complete") {
1282
+ throw new Error(
1283
+ "web-session cleanup admission cannot be released before cleanup completion",
1284
+ );
1285
+ }
1286
+ removePrivateStateFileIfUnchanged(
1287
+ pathFor(current.claim.realmKey, environment),
1288
+ { expectedCurrentContentSha256: current.contentSha256 },
1289
+ environment,
1290
+ );
1291
+ // Cleanup-complete is itself sufficient retry proof. Another invoker or
1292
+ // doctor may already have retired this exact claim and published a
1293
+ // successor; a false CAS must never remove that successor or turn the
1294
+ // completed operation into a failure.
1295
+ released = true;
1296
+ },
1297
+ };
1298
+ return Object.freeze(result);
1299
+ }
1300
+
1301
+ function createClaim(
1302
+ identity: WebSessionCleanupAdmissionIdentity,
1303
+ acquiredAt: Date,
1304
+ ): WebSessionCleanupAdmissionClaim {
1305
+ const realmKey = webSessionCleanupRealmKey(
1306
+ identity.surfaceId,
1307
+ identity.authId,
1308
+ );
1309
+ const processIdentity = currentProcessStartIdentity();
1310
+ return parseWebSessionCleanupAdmissionClaim({
1311
+ schemaVersion: ADMISSION_SCHEMA_VERSION,
1312
+ realmKey,
1313
+ runId: identity.runId,
1314
+ pluginId: identity.pluginId,
1315
+ pluginVersion: identity.pluginVersion,
1316
+ pluginImplementationHash: identity.pluginImplementationHash,
1317
+ adapterId: identity.adapterId,
1318
+ adapterHash: identity.adapterHash,
1319
+ surfaceId: identity.surfaceId,
1320
+ authId: identity.authId,
1321
+ authHash: identity.authHash,
1322
+ ...(identity.transport === undefined
1323
+ || identity.executionIdentityHash === undefined
1324
+ ? {}
1325
+ : {
1326
+ transport: identity.transport,
1327
+ executionIdentityHash: identity.executionIdentityHash,
1328
+ }),
1329
+ owner: {
1330
+ pid: process.pid,
1331
+ token: randomUUID(),
1332
+ ...processIdentity,
1333
+ },
1334
+ acquiredAt: acquiredAt.toISOString(),
1335
+ containment: { status: "parent-owned" },
1336
+ resources: [],
1337
+ recovery: { status: "idle" },
1338
+ });
1339
+ }
1340
+
1341
+ type SameBootCleanupUnsafeRecovery =
1342
+ | "repaired"
1343
+ | "live-owner"
1344
+ | "recovery-active"
1345
+ | "owner-unknown"
1346
+ | "proof-unavailable"
1347
+ | "artifact-conflict"
1348
+ | "claim-conflict";
1349
+
1350
+ type CleanupRecoveryLeaseResult =
1351
+ | {
1352
+ readonly status: "acquired";
1353
+ readonly snapshot: WebSessionCleanupAdmissionSnapshot;
1354
+ }
1355
+ | {
1356
+ readonly status:
1357
+ | "live-owner"
1358
+ | "recovery-active"
1359
+ | "owner-unknown"
1360
+ | "claim-conflict";
1361
+ };
1362
+
1363
+ function acquireCleanupRecoveryLease(
1364
+ entry: WebSessionCleanupAdmissionSnapshot,
1365
+ environment: Environment,
1366
+ inspectOwner: (owner: ProcessOwnerIdentity) => ProcessOwnerStatus,
1367
+ acquiredAt = new Date(),
1368
+ ): CleanupRecoveryLeaseResult {
1369
+ const ownerStatus = inspectOwner(entry.claim.owner);
1370
+ if (ownerStatus === "exact-live-owner") {
1371
+ return { status: "live-owner" };
1372
+ }
1373
+ if (ownerStatus === "unknown") {
1374
+ return { status: "owner-unknown" };
1375
+ }
1376
+ if (
1377
+ entry.claim.schemaVersion === ADMISSION_SCHEMA_VERSION
1378
+ && entry.claim.recovery.status === "active"
1379
+ ) {
1380
+ const recoveryOwnerStatus = inspectOwner(entry.claim.recovery.owner);
1381
+ if (recoveryOwnerStatus === "exact-live-owner") {
1382
+ return { status: "recovery-active" };
1383
+ }
1384
+ if (recoveryOwnerStatus === "unknown") {
1385
+ return { status: "owner-unknown" };
1386
+ }
1387
+ }
1388
+ const processIdentity = currentProcessStartIdentity();
1389
+ try {
1390
+ const snapshot = replaceClaim(
1391
+ entry,
1392
+ parseWebSessionCleanupAdmissionClaim({
1393
+ ...entry.claim,
1394
+ schemaVersion: ADMISSION_SCHEMA_VERSION,
1395
+ recovery: {
1396
+ status: "active",
1397
+ owner: {
1398
+ pid: process.pid,
1399
+ token: randomUUID(),
1400
+ ...processIdentity,
1401
+ },
1402
+ acquiredAt: acquiredAt.toISOString(),
1403
+ },
1404
+ }),
1405
+ environment,
1406
+ );
1407
+ return { status: "acquired", snapshot };
1408
+ } catch {
1409
+ return { status: "claim-conflict" };
1410
+ }
1411
+ }
1412
+
1413
+ function exactCurrentClaim(
1414
+ expected: WebSessionCleanupAdmissionSnapshot,
1415
+ environment: Environment,
1416
+ ): WebSessionCleanupAdmissionSnapshot | null {
1417
+ const current = readWebSessionCleanupAdmission(
1418
+ expected.claim.realmKey,
1419
+ environment,
1420
+ );
1421
+ return current !== null
1422
+ && current.contentSha256 === expected.contentSha256
1423
+ ? current
1424
+ : null;
1425
+ }
1426
+
1427
+ function releaseCleanupRecoveryLease(
1428
+ expected: WebSessionCleanupAdmissionSnapshot,
1429
+ environment: Environment,
1430
+ ): boolean {
1431
+ if (
1432
+ expected.claim.schemaVersion !== ADMISSION_SCHEMA_VERSION
1433
+ || expected.claim.recovery.status !== "active"
1434
+ ) return false;
1435
+ try {
1436
+ replaceClaim(
1437
+ expected,
1438
+ parseWebSessionCleanupAdmissionClaim({
1439
+ ...expected.claim,
1440
+ recovery: { status: "idle" },
1441
+ }),
1442
+ environment,
1443
+ );
1444
+ return true;
1445
+ } catch {
1446
+ return false;
1447
+ }
1448
+ }
1449
+
1450
+ function replaceBrowserResource(
1451
+ current: WebSessionCleanupAdmissionSnapshot,
1452
+ resourceId: string,
1453
+ identity: BrowserCleanupResourceIdentityV2,
1454
+ status:
1455
+ | "active"
1456
+ | "browser-closed-artifacts"
1457
+ | "browser-quiescent-artifacts",
1458
+ environment: Environment,
1459
+ afterCommitForTest?: () => void,
1460
+ ): WebSessionCleanupAdmissionSnapshot {
1461
+ let found = false;
1462
+ const resources = current.claim.resources.map((resource) => {
1463
+ if (resource.resourceId !== resourceId) return resource;
1464
+ if (
1465
+ resource.status === "unpublished"
1466
+ || resource.status === "browser-quiescent-artifacts"
1467
+ || resource.identity.kind === "local-cli-private-root-v1"
1468
+ || !browserCleanupResourceExtends(resource.identity, identity)
1469
+ ) {
1470
+ throw new Error(
1471
+ "web-session cleanup browser recovery changed resource identity",
1472
+ );
1473
+ }
1474
+ found = true;
1475
+ return status === "browser-quiescent-artifacts"
1476
+ ? Object.freeze({
1477
+ resourceId,
1478
+ status,
1479
+ identity,
1480
+ removedRoots: Object.freeze([] as BrowserCleanupRootName[]),
1481
+ })
1482
+ : Object.freeze({ resourceId, status, identity });
1483
+ });
1484
+ if (!found) {
1485
+ throw new Error("web-session cleanup browser recovery resource is absent");
1486
+ }
1487
+ return replaceClaimOrAdoptCommitted(
1488
+ current,
1489
+ parseWebSessionCleanupAdmissionClaim({
1490
+ ...current.claim,
1491
+ resources,
1492
+ }),
1493
+ environment,
1494
+ afterCommitForTest,
1495
+ );
1496
+ }
1497
+
1498
+ function journalBrowserRootRemoved(
1499
+ current: WebSessionCleanupAdmissionSnapshot,
1500
+ resourceId: string,
1501
+ root: BrowserCleanupRootName,
1502
+ environment: Environment,
1503
+ afterCommitForTest?: () => void,
1504
+ ): WebSessionCleanupAdmissionSnapshot {
1505
+ let found = false;
1506
+ const resources = current.claim.resources.map((resource) => {
1507
+ if (resource.resourceId !== resourceId) return resource;
1508
+ if (resource.status !== "browser-quiescent-artifacts") {
1509
+ throw new Error(
1510
+ "web-session cleanup browser root removal is not quiescent",
1511
+ );
1512
+ }
1513
+ const expected = (["artifacts", "socket"] as const)[
1514
+ resource.removedRoots.length
1515
+ ];
1516
+ if (expected !== root) {
1517
+ throw new Error(
1518
+ "web-session cleanup browser root removal order changed",
1519
+ );
1520
+ }
1521
+ found = true;
1522
+ return Object.freeze({
1523
+ ...resource,
1524
+ removedRoots: Object.freeze([
1525
+ ...resource.removedRoots,
1526
+ root,
1527
+ ] as BrowserCleanupRootName[]),
1528
+ });
1529
+ });
1530
+ if (!found) {
1531
+ throw new Error("web-session cleanup browser recovery resource is absent");
1532
+ }
1533
+ return replaceClaimOrAdoptCommitted(
1534
+ current,
1535
+ parseWebSessionCleanupAdmissionClaim({
1536
+ ...current.claim,
1537
+ resources,
1538
+ }),
1539
+ environment,
1540
+ afterCommitForTest,
1541
+ );
1542
+ }
1543
+
1544
+ type BrowserRootFinalization =
1545
+ | {
1546
+ readonly status: "complete";
1547
+ readonly snapshot: WebSessionCleanupAdmissionSnapshot;
1548
+ }
1549
+ | {
1550
+ readonly status: "artifact-conflict" | "claim-conflict";
1551
+ readonly snapshot: WebSessionCleanupAdmissionSnapshot;
1552
+ };
1553
+
1554
+ /**
1555
+ * Resume an identity-bound root-removal journal. Full quiescence is refreshed
1556
+ * immediately before artifacts removal. The still-bound socket then supports
1557
+ * a session/owner/CDP reproof immediately before its own removal. A missing
1558
+ * root is accepted only after the exact resource entered the durable
1559
+ * quiescent phase; a replacement remains a hard conflict.
1560
+ */
1561
+ async function finalizeRecoveredBrowserResource(
1562
+ initial: WebSessionCleanupAdmissionSnapshot,
1563
+ resourceId: string,
1564
+ environment: Environment,
1565
+ lifecycle: AgentBrowserLifecycleDependencies,
1566
+ ): Promise<BrowserRootFinalization> {
1567
+ let current = initial;
1568
+ for (const rootName of ["artifacts", "socket"] as const) {
1569
+ const selected = current.claim.resources.find((resource) =>
1570
+ resource.resourceId === resourceId
1571
+ );
1572
+ if (
1573
+ selected === undefined
1574
+ || selected.status !== "browser-quiescent-artifacts"
1575
+ ) {
1576
+ return { status: "claim-conflict", snapshot: current };
1577
+ }
1578
+ const rootStatus = browserCleanupResourceRootStatus(
1579
+ selected.identity,
1580
+ rootName,
1581
+ );
1582
+ if (selected.removedRoots.includes(rootName)) {
1583
+ if (rootStatus !== "absent") {
1584
+ return { status: "artifact-conflict", snapshot: current };
1585
+ }
1586
+ continue;
1587
+ }
1588
+ if (exactCurrentClaim(current, environment) === null) {
1589
+ return { status: "claim-conflict", snapshot: current };
1590
+ }
1591
+ if (rootStatus === "conflict") {
1592
+ return { status: "artifact-conflict", snapshot: current };
1593
+ }
1594
+ if (rootStatus === "match") {
1595
+ try {
1596
+ if (rootName === "artifacts") {
1597
+ if (
1598
+ browserCleanupResourceRootStatus(
1599
+ selected.identity,
1600
+ "socket",
1601
+ ) !== "match"
1602
+ ) {
1603
+ return { status: "artifact-conflict", snapshot: current };
1604
+ }
1605
+ await refreshBrowserCleanupResourceQuiescence(
1606
+ selected.identity,
1607
+ lifecycle,
1608
+ );
1609
+ } else {
1610
+ if (
1611
+ browserCleanupResourceRootStatus(
1612
+ selected.identity,
1613
+ "artifacts",
1614
+ ) !== "absent"
1615
+ ) {
1616
+ return { status: "artifact-conflict", snapshot: current };
1617
+ }
1618
+ await reproveBrowserCleanupAfterArtifactsRemoval(
1619
+ selected.identity,
1620
+ lifecycle,
1621
+ );
1622
+ }
1623
+ } catch {
1624
+ return { status: "artifact-conflict", snapshot: current };
1625
+ }
1626
+ if (exactCurrentClaim(current, environment) === null) {
1627
+ return { status: "claim-conflict", snapshot: current };
1628
+ }
1629
+ if (
1630
+ browserCleanupResourceRootStatus(
1631
+ selected.identity,
1632
+ rootName,
1633
+ ) !== "match"
1634
+ ) {
1635
+ return { status: "artifact-conflict", snapshot: current };
1636
+ }
1637
+ const root = rootName === "artifacts"
1638
+ ? {
1639
+ path: selected.identity.artifactsDirectory,
1640
+ identity: selected.identity.artifactsDirectoryIdentity,
1641
+ }
1642
+ : {
1643
+ path: selected.identity.socketDirectory,
1644
+ identity: selected.identity.socketDirectoryIdentity,
1645
+ };
1646
+ try {
1647
+ if (!removePrivateDirectoryTree(root.path, {
1648
+ device: root.identity.device,
1649
+ inode: root.identity.inode,
1650
+ birthtimeNs: root.identity.birthtimeNs,
1651
+ })) {
1652
+ return { status: "artifact-conflict", snapshot: current };
1653
+ }
1654
+ } catch {
1655
+ return { status: "artifact-conflict", snapshot: current };
1656
+ }
1657
+ }
1658
+ if (exactCurrentClaim(current, environment) === null) {
1659
+ return { status: "claim-conflict", snapshot: current };
1660
+ }
1661
+ try {
1662
+ current = journalBrowserRootRemoved(
1663
+ current,
1664
+ resourceId,
1665
+ rootName,
1666
+ environment,
1667
+ );
1668
+ } catch {
1669
+ return { status: "claim-conflict", snapshot: current };
1670
+ }
1671
+ }
1672
+ return { status: "complete", snapshot: current };
1673
+ }
1674
+
1675
+ async function recoverBrowserCleanupUnsafe(
1676
+ entry: WebSessionCleanupAdmissionSnapshot,
1677
+ environment: Environment,
1678
+ inspectOwner: (owner: ProcessOwnerIdentity) => ProcessOwnerStatus,
1679
+ lifecycle: AgentBrowserLifecycleDependencies,
1680
+ ): Promise<SameBootCleanupUnsafeRecovery> {
1681
+ const lease = acquireCleanupRecoveryLease(
1682
+ entry,
1683
+ environment,
1684
+ inspectOwner,
1685
+ );
1686
+ if (lease.status !== "acquired") return lease.status;
1687
+ let current = lease.snapshot;
1688
+ if (
1689
+ current.claim.resources.length === 0
1690
+ || current.claim.resources.some((resource) =>
1691
+ resource.status === "unpublished"
1692
+ || resource.identity.kind === "local-cli-private-root-v1"
1693
+ )
1694
+ ) {
1695
+ releaseCleanupRecoveryLease(current, environment);
1696
+ return "proof-unavailable";
1697
+ }
1698
+ for (const initialResource of current.claim.resources) {
1699
+ const selected = current.claim.resources.find((resource) =>
1700
+ resource.resourceId === initialResource.resourceId
1701
+ );
1702
+ if (selected === undefined || selected.status === "unpublished") {
1703
+ releaseCleanupRecoveryLease(current, environment);
1704
+ return "claim-conflict";
1705
+ }
1706
+ if (selected.status === "browser-quiescent-artifacts") {
1707
+ const finalization = await finalizeRecoveredBrowserResource(
1708
+ current,
1709
+ selected.resourceId,
1710
+ environment,
1711
+ lifecycle,
1712
+ );
1713
+ current = finalization.snapshot;
1714
+ if (finalization.status !== "complete") {
1715
+ releaseCleanupRecoveryLease(current, environment);
1716
+ return finalization.status;
1717
+ }
1718
+ continue;
1719
+ }
1720
+ if (selected.identity.kind === "local-cli-private-root-v1") {
1721
+ releaseCleanupRecoveryLease(current, environment);
1722
+ return "proof-unavailable";
1723
+ }
1724
+ try {
1725
+ if (exactCurrentClaim(current, environment) === null) {
1726
+ return "claim-conflict";
1727
+ }
1728
+ let pinned: BrowserCleanupResourceIdentityV2;
1729
+ if (selected.identity.kind === "agent-browser-session-v1") {
1730
+ try {
1731
+ pinned = await adoptLiveLegacyBrowserCleanupResource(
1732
+ selected.identity,
1733
+ lifecycle,
1734
+ );
1735
+ } catch (error) {
1736
+ if (!releaseCleanupRecoveryLease(current, environment)) {
1737
+ return "claim-conflict";
1738
+ }
1739
+ return error instanceof AgentBrowserLiveControlUnavailableError
1740
+ ? "proof-unavailable"
1741
+ : "artifact-conflict";
1742
+ }
1743
+ } else {
1744
+ pinned = selected.identity.phase === "launch-intent"
1745
+ && selected.identity.control === null
1746
+ ? await bindLiveAgentBrowserCleanupResource(
1747
+ selected.identity,
1748
+ lifecycle,
1749
+ )
1750
+ : selected.identity;
1751
+ }
1752
+ if (
1753
+ selected.identity.kind !== "agent-browser-session-v2"
1754
+ || canonicalJson(selected.identity) !== canonicalJson(pinned)
1755
+ ) {
1756
+ current = replaceBrowserResource(
1757
+ current,
1758
+ selected.resourceId,
1759
+ pinned,
1760
+ selected.status,
1761
+ environment,
1762
+ );
1763
+ }
1764
+ if (exactCurrentClaim(current, environment) === null) {
1765
+ return "claim-conflict";
1766
+ }
1767
+ if (pinned.phase === "prepared") {
1768
+ await provePreparedAgentBrowserCleanupResourceQuiescent(
1769
+ pinned,
1770
+ lifecycle,
1771
+ );
1772
+ } else {
1773
+ await recoverPinnedAgentBrowserCleanupResource(pinned, lifecycle);
1774
+ }
1775
+ if (exactCurrentClaim(current, environment) === null) {
1776
+ return "claim-conflict";
1777
+ }
1778
+ current = replaceBrowserResource(
1779
+ current,
1780
+ selected.resourceId,
1781
+ pinned,
1782
+ "browser-quiescent-artifacts",
1783
+ environment,
1784
+ );
1785
+ } catch {
1786
+ return releaseCleanupRecoveryLease(current, environment)
1787
+ ? "artifact-conflict"
1788
+ : "claim-conflict";
1789
+ }
1790
+ const finalization = await finalizeRecoveredBrowserResource(
1791
+ current,
1792
+ selected.resourceId,
1793
+ environment,
1794
+ lifecycle,
1795
+ );
1796
+ current = finalization.snapshot;
1797
+ if (finalization.status !== "complete") {
1798
+ releaseCleanupRecoveryLease(current, environment);
1799
+ return finalization.status;
1800
+ }
1801
+ }
1802
+ return removePrivateStateFileIfUnchanged(
1803
+ pathFor(current.claim.realmKey, environment),
1804
+ { expectedCurrentContentSha256: current.contentSha256 },
1805
+ environment,
1806
+ )
1807
+ ? "repaired"
1808
+ : "claim-conflict";
1809
+ }
1810
+
1811
+ function removeRecoverableLocalCliRoots(
1812
+ claim: WebSessionCleanupAdmissionClaim,
1813
+ inspectOwner: (owner: ProcessOwnerIdentity) => ProcessOwnerStatus,
1814
+ ): SameBootCleanupUnsafeRecovery {
1815
+ if (
1816
+ claim.resources.length === 0
1817
+ || claim.resources.some((resource) =>
1818
+ resource.status !== "active"
1819
+ || resource.identity.kind !== "local-cli-private-root-v1"
1820
+ )
1821
+ ) {
1822
+ return "proof-unavailable";
1823
+ }
1824
+ const owner = inspectOwner(claim.owner);
1825
+ if (owner === "exact-live-owner") return "live-owner";
1826
+ if (owner === "unknown") return "owner-unknown";
1827
+ for (const resource of claim.resources) {
1828
+ if (
1829
+ resource.status !== "active"
1830
+ || resource.identity.kind !== "local-cli-private-root-v1"
1831
+ ) {
1832
+ return "proof-unavailable";
1833
+ }
1834
+ const groupStatus = localCliCleanupProcessGroupStatus(
1835
+ resource.identity,
1836
+ inspectOwner,
1837
+ );
1838
+ if (groupStatus === "active") return "live-owner";
1839
+ if (groupStatus !== "quiescent") return "proof-unavailable";
1840
+ }
1841
+ try {
1842
+ for (const resource of claim.resources) {
1843
+ if (
1844
+ resource.status !== "active"
1845
+ || resource.identity.kind !== "local-cli-private-root-v1"
1846
+ ) {
1847
+ return "proof-unavailable";
1848
+ }
1849
+ removePrivateDirectoryTree(resource.identity.root.path, {
1850
+ device: resource.identity.root.device,
1851
+ inode: resource.identity.root.inode,
1852
+ birthtimeNs: resource.identity.root.birthtimeNs,
1853
+ });
1854
+ }
1855
+ } catch {
1856
+ return "artifact-conflict";
1857
+ }
1858
+ return "repaired";
1859
+ }
1860
+
1861
+ function removePriorBootQuiescentLocalCliRoots(
1862
+ claim: WebSessionCleanupAdmissionClaim,
1863
+ ): boolean {
1864
+ if (
1865
+ claim.resources.length === 0
1866
+ || claim.resources.some((resource) =>
1867
+ resource.status !== "active"
1868
+ || resource.identity.kind !== "local-cli-private-root-v1"
1869
+ )
1870
+ ) return false;
1871
+ try {
1872
+ for (const resource of claim.resources) {
1873
+ if (
1874
+ resource.status !== "active"
1875
+ || resource.identity.kind !== "local-cli-private-root-v1"
1876
+ ) {
1877
+ return false;
1878
+ }
1879
+ removePrivateDirectoryTree(resource.identity.root.path, {
1880
+ device: resource.identity.root.device,
1881
+ inode: resource.identity.root.inode,
1882
+ birthtimeNs: resource.identity.root.birthtimeNs,
1883
+ });
1884
+ }
1885
+ return true;
1886
+ } catch {
1887
+ return false;
1888
+ }
1889
+ }
1890
+
1891
+ function recoverSameBootCleanupUnsafe(
1892
+ entry: WebSessionCleanupAdmissionSnapshot,
1893
+ environment: Environment,
1894
+ inspectOwner: (owner: ProcessOwnerIdentity) => ProcessOwnerStatus,
1895
+ ): SameBootCleanupUnsafeRecovery {
1896
+ if (
1897
+ entry.claim.containment.status !== "cleanup-unsafe"
1898
+ && entry.claim.containment.status !== "resource-active"
1899
+ ) {
1900
+ return "proof-unavailable";
1901
+ }
1902
+ if (entry.claim.transport !== "local-cli") {
1903
+ return "proof-unavailable";
1904
+ }
1905
+ const lease = acquireCleanupRecoveryLease(
1906
+ entry,
1907
+ environment,
1908
+ inspectOwner,
1909
+ );
1910
+ if (lease.status !== "acquired") return lease.status;
1911
+ const localRecovery = removeRecoverableLocalCliRoots(
1912
+ lease.snapshot.claim,
1913
+ inspectOwner,
1914
+ );
1915
+ if (localRecovery !== "repaired") {
1916
+ return releaseCleanupRecoveryLease(lease.snapshot, environment)
1917
+ ? localRecovery
1918
+ : "claim-conflict";
1919
+ }
1920
+ return removePrivateStateFileIfUnchanged(
1921
+ pathFor(lease.snapshot.claim.realmKey, environment),
1922
+ { expectedCurrentContentSha256: lease.snapshot.contentSha256 },
1923
+ environment,
1924
+ )
1925
+ ? "repaired"
1926
+ : "claim-conflict";
1927
+ }
1928
+
1929
+ function blockedAdmissionGuidance(
1930
+ claim: WebSessionCleanupAdmissionClaim,
1931
+ recovery: SameBootCleanupUnsafeRecovery | null,
1932
+ ): string {
1933
+ const realm = `${claim.surfaceId}/${claim.authId}`;
1934
+ const transport = claim.transport === "local-cli"
1935
+ ? "local CLI"
1936
+ : "authenticated web";
1937
+ if (recovery === "live-owner") {
1938
+ return `${transport} auth realm ${realm} has active or cleanup-unsafe state still owned by an active run; wait for it to finish`;
1939
+ }
1940
+ if (recovery === "recovery-active") {
1941
+ return `${transport} auth realm ${realm} cleanup recovery is active; wait for ghostget doctor to finish`;
1942
+ }
1943
+ if (recovery === "owner-unknown") {
1944
+ return `${transport} auth realm ${realm} owner liveness cannot be proved; run ghostget doctor again after process inspection becomes available`;
1945
+ }
1946
+ if (recovery === "artifact-conflict") {
1947
+ return `${transport} auth realm ${realm} has identity-changed private cleanup artifacts; retry is unsafe until exact session recovery succeeds`;
1948
+ }
1949
+ if (recovery === "claim-conflict") {
1950
+ return `${transport} auth realm ${realm} cleanup recovery changed concurrently; run ghostget doctor before retrying`;
1951
+ }
1952
+ if (
1953
+ claim.containment.status === "cleanup-unsafe"
1954
+ && recovery === "proof-unavailable"
1955
+ ) {
1956
+ return `${transport} auth realm ${realm} has cleanup-unsafe state without exact quiescence evidence; run ghostget doctor to recover the exact private session before retrying`;
1957
+ }
1958
+ if (claim.containment.status === "resource-active") {
1959
+ return `${transport} auth realm ${realm} has a resource-active crash boundary; run ghostget doctor to recover the exact private session before retrying`;
1960
+ }
1961
+ return `${transport} auth realm ${realm} has active or cleanup-unsafe state; wait for the active run, or run ghostget doctor before retrying`;
1962
+ }
1963
+
1964
+ export class WebSessionCleanupAdmissionBlockedError extends Error {
1965
+ constructor(message: string, cause?: unknown) {
1966
+ super(message, cause === undefined ? undefined : { cause });
1967
+ this.name = "WebSessionCleanupAdmissionBlockedError";
1968
+ }
1969
+ }
1970
+
1971
+ function cleanupAdmissionBlocked(error: unknown): WebSessionCleanupAdmissionBlockedError {
1972
+ if (error instanceof WebSessionCleanupAdmissionBlockedError) return error;
1973
+ return new WebSessionCleanupAdmissionBlockedError(
1974
+ error instanceof Error
1975
+ ? error.message
1976
+ : "web-session cleanup admission could not be acquired",
1977
+ error,
1978
+ );
1979
+ }
1980
+
1981
+ function acquireWebSessionCleanupAdmissionCore(
1982
+ claim: WebSessionCleanupAdmissionClaim,
1983
+ environment: Environment,
1984
+ dependencies: WebSessionCleanupAdmissionControllerDependencies = {},
1985
+ ): WebSessionCleanupAdmissionController {
1986
+ const admissionDirectory = directory(environment);
1987
+ ensurePrivateStateDirectory(admissionDirectory, environment);
1988
+ for (
1989
+ let attempt = 0;
1990
+ attempt < MAX_ACQUISITION_ATTEMPTS;
1991
+ attempt += 1
1992
+ ) {
1993
+ const existing = readWebSessionCleanupAdmission(
1994
+ claim.realmKey,
1995
+ environment,
1996
+ );
1997
+ if (existing !== null) {
1998
+ if (
1999
+ existing.claim.schemaVersion === ADMISSION_SCHEMA_VERSION
2000
+ && existing.claim.recovery.status === "active"
2001
+ ) {
2002
+ throw new WebSessionCleanupAdmissionBlockedError(
2003
+ blockedAdmissionGuidance(existing.claim, "recovery-active"),
2004
+ );
2005
+ }
2006
+ const containment = existing.claim.containment.status;
2007
+ const currentBootId = claim.owner.bootId;
2008
+ let sameBootUnsafeRecovery:
2009
+ | SameBootCleanupUnsafeRecovery
2010
+ | null = null;
2011
+ if (
2012
+ existing.claim.transport === "local-cli"
2013
+ && (
2014
+ containment === "cleanup-unsafe"
2015
+ || containment === "resource-active"
2016
+ )
2017
+ && existing.claim.owner.bootId === currentBootId
2018
+ ) {
2019
+ sameBootUnsafeRecovery = recoverSameBootCleanupUnsafe(
2020
+ existing,
2021
+ environment,
2022
+ processOwnerStatus,
2023
+ );
2024
+ if (sameBootUnsafeRecovery === "repaired") continue;
2025
+ if (sameBootUnsafeRecovery === "claim-conflict") continue;
2026
+ }
2027
+ const priorBootQuiescent = (
2028
+ containment === "resource-active"
2029
+ || containment === "cleanup-unsafe"
2030
+ )
2031
+ && existing.claim.owner.bootId !== currentBootId;
2032
+ const automaticallyRepairable = containment === "cleanup-complete"
2033
+ || (
2034
+ containment === "parent-owned"
2035
+ && processOwnerStatus(existing.claim.owner)
2036
+ === "different-or-dead"
2037
+ )
2038
+ || (
2039
+ (
2040
+ containment === "resource-active"
2041
+ || containment === "cleanup-unsafe"
2042
+ )
2043
+ && existing.claim.transport === "local-cli"
2044
+ && existing.claim.owner.bootId !== currentBootId
2045
+ );
2046
+ if (automaticallyRepairable) {
2047
+ if (
2048
+ priorBootQuiescent
2049
+ && !removePriorBootQuiescentLocalCliRoots(existing.claim)
2050
+ ) {
2051
+ throw new WebSessionCleanupAdmissionBlockedError(
2052
+ blockedAdmissionGuidance(existing.claim, "artifact-conflict"),
2053
+ );
2054
+ }
2055
+ removePrivateStateFileIfUnchanged(
2056
+ pathFor(claim.realmKey, environment),
2057
+ { expectedCurrentContentSha256: existing.contentSha256 },
2058
+ environment,
2059
+ );
2060
+ continue;
2061
+ }
2062
+ throw new WebSessionCleanupAdmissionBlockedError(
2063
+ blockedAdmissionGuidance(
2064
+ existing.claim,
2065
+ sameBootUnsafeRecovery,
2066
+ ),
2067
+ );
2068
+ }
2069
+ let created: { readonly created: boolean };
2070
+ try {
2071
+ created = createPrivateJsonIfAbsent(
2072
+ pathFor(claim.realmKey, environment),
2073
+ claim,
2074
+ { environment },
2075
+ );
2076
+ } catch (error) {
2077
+ if (
2078
+ error instanceof Error
2079
+ && error.message.includes("state file mutation is already active")
2080
+ ) {
2081
+ continue;
2082
+ }
2083
+ throw error;
2084
+ }
2085
+ if (created.created) {
2086
+ return controller(claimSnapshot(claim), environment, dependencies);
2087
+ }
2088
+ }
2089
+ throw new WebSessionCleanupAdmissionBlockedError(
2090
+ `${claim.transport === "local-cli" ? "local CLI" : "authenticated web"} auth realm ${claim.surfaceId}/${claim.authId} cleanup admission could not be acquired`,
2091
+ );
2092
+ }
2093
+
2094
+ export function acquireWebSessionCleanupAdmission(
2095
+ identity: WebSessionCleanupAdmissionIdentity,
2096
+ environment: Environment = process.env,
2097
+ acquiredAt = new Date(),
2098
+ dependencies: WebSessionCleanupAdmissionControllerDependencies = {},
2099
+ ): WebSessionCleanupAdmissionController {
2100
+ const claim = createClaim(identity, acquiredAt);
2101
+ try {
2102
+ return acquireWebSessionCleanupAdmissionCore(
2103
+ claim,
2104
+ environment,
2105
+ dependencies,
2106
+ );
2107
+ } catch (error) {
2108
+ throw cleanupAdmissionBlocked(error);
2109
+ }
2110
+ }
2111
+
2112
+ /**
2113
+ * The operation result and cleanup safety are independent. The operation may
2114
+ * return a truthful zero-dispatch failure after its cleanup barrier rejects;
2115
+ * the retained durable admission, rather than a fabricated dispatch state,
2116
+ * prevents another run in that auth realm.
2117
+ */
2118
+ export async function withWebSessionCleanupAdmission<T>(
2119
+ identity: WebSessionCleanupAdmissionIdentity,
2120
+ environment: Environment,
2121
+ operation: (
2122
+ registerCleanupBarrier: WebSessionCleanupBarrierRegistrar,
2123
+ ) => Promise<T>,
2124
+ acquiredAt = new Date(),
2125
+ onAdmissionBlocked?: (
2126
+ error: WebSessionCleanupAdmissionBlockedError,
2127
+ ) => Promise<T>,
2128
+ ): Promise<T> {
2129
+ let admission: WebSessionCleanupAdmissionController;
2130
+ try {
2131
+ admission = acquireWebSessionCleanupAdmission(
2132
+ identity,
2133
+ environment,
2134
+ acquiredAt,
2135
+ );
2136
+ } catch (error) {
2137
+ if (
2138
+ error instanceof WebSessionCleanupAdmissionBlockedError
2139
+ && onAdmissionBlocked !== undefined
2140
+ ) {
2141
+ return onAdmissionBlocked(error);
2142
+ }
2143
+ throw error;
2144
+ }
2145
+ let outcome:
2146
+ | { readonly status: "fulfilled"; readonly value: T }
2147
+ | { readonly status: "rejected"; readonly reason: unknown };
2148
+ try {
2149
+ try {
2150
+ outcome = {
2151
+ status: "fulfilled",
2152
+ value: await operation(admission.registerCleanupBarrier),
2153
+ };
2154
+ } catch (reason) {
2155
+ outcome = { status: "rejected", reason };
2156
+ }
2157
+ admission.closeRegistration();
2158
+ const cleanup = await Promise.allSettled(admission.barriers);
2159
+ if (cleanup.some((result) => result.status === "rejected")) {
2160
+ admission.cleanupUnsafe();
2161
+ } else {
2162
+ admission.cleanupComplete();
2163
+ admission.release();
2164
+ }
2165
+ if (outcome.status === "rejected") throw outcome.reason;
2166
+ return outcome.value;
2167
+ } catch (error) {
2168
+ admission.closeRegistration();
2169
+ if (admission.current.claim.containment.status === "parent-owned") {
2170
+ admission.cleanupComplete();
2171
+ admission.release();
2172
+ }
2173
+ throw error;
2174
+ }
2175
+ }
2176
+
2177
+ export async function recoverWebSessionCleanupAdmissions(
2178
+ environment: Environment = process.env,
2179
+ ): Promise<WebSessionCleanupAdmissionRecoveryReport> {
2180
+ return recoverWebSessionCleanupAdmissionsCore(
2181
+ environment,
2182
+ {
2183
+ inspectOwner: processOwnerStatus,
2184
+ currentBootId: currentProcessStartIdentity().bootId,
2185
+ browserLifecycle: {},
2186
+ },
2187
+ );
2188
+ }
2189
+
2190
+ export type WebSessionCleanupAdmissionRecoveryDependencies = {
2191
+ readonly inspectOwner: (
2192
+ owner: ProcessOwnerIdentity,
2193
+ ) => ProcessOwnerStatus;
2194
+ readonly currentBootId: string;
2195
+ readonly browserLifecycle: AgentBrowserLifecycleDependencies;
2196
+ };
2197
+
2198
+ /** Internal deterministic seam; public doctor always supplies kernel probes. */
2199
+ export async function recoverWebSessionCleanupAdmissionsCore(
2200
+ environment: Environment,
2201
+ dependencies: WebSessionCleanupAdmissionRecoveryDependencies,
2202
+ ): Promise<WebSessionCleanupAdmissionRecoveryReport> {
2203
+ const {
2204
+ inspectOwner,
2205
+ currentBootId,
2206
+ browserLifecycle,
2207
+ } = dependencies;
2208
+ digest(currentBootId, "current boot identity");
2209
+ const entries = listWebSessionCleanupAdmissions(environment);
2210
+ const issues: WebSessionCleanupAdmissionRecoveryIssue[] = [];
2211
+ let repaired = 0;
2212
+ let active = 0;
2213
+ let retained = 0;
2214
+ let invalid = 0;
2215
+ for (const entry of entries) {
2216
+ if ("invalid" in entry) {
2217
+ invalid += 1;
2218
+ issues.push({
2219
+ coordinate: entry.coordinate,
2220
+ kind: "invalid-admission",
2221
+ });
2222
+ continue;
2223
+ }
2224
+ const { claim } = entry;
2225
+ if (
2226
+ claim.schemaVersion === ADMISSION_SCHEMA_VERSION
2227
+ && claim.recovery.status === "active"
2228
+ ) {
2229
+ const recoveryOwner = inspectOwner(claim.recovery.owner);
2230
+ if (recoveryOwner === "exact-live-owner") {
2231
+ active += 1;
2232
+ issues.push({
2233
+ coordinate: claim.realmKey,
2234
+ kind: "recovery-active",
2235
+ });
2236
+ continue;
2237
+ }
2238
+ if (recoveryOwner === "unknown") {
2239
+ retained += 1;
2240
+ issues.push({
2241
+ coordinate: claim.realmKey,
2242
+ kind: "owner-unknown",
2243
+ });
2244
+ continue;
2245
+ }
2246
+ }
2247
+ const containment = claim.containment.status;
2248
+ let repairable = containment === "cleanup-complete";
2249
+ if (containment === "parent-owned") {
2250
+ const owner = inspectOwner(claim.owner);
2251
+ if (owner === "exact-live-owner") {
2252
+ active += 1;
2253
+ continue;
2254
+ }
2255
+ if (owner === "unknown") {
2256
+ retained += 1;
2257
+ issues.push({
2258
+ coordinate: claim.realmKey,
2259
+ kind: "owner-unknown",
2260
+ });
2261
+ continue;
2262
+ }
2263
+ repairable = true;
2264
+ } else if (
2265
+ containment === "resource-active"
2266
+ || containment === "cleanup-unsafe"
2267
+ ) {
2268
+ const hasPublishedLocalCliResource = claim.resources.some((resource) =>
2269
+ resource.status !== "unpublished"
2270
+ && resource.identity.kind === "local-cli-private-root-v1"
2271
+ );
2272
+ const hasPublishedBrowserResource = claim.resources.some((resource) =>
2273
+ resource.status !== "unpublished"
2274
+ && resource.identity.kind !== "local-cli-private-root-v1"
2275
+ );
2276
+ if (
2277
+ hasPublishedLocalCliResource
2278
+ && hasPublishedBrowserResource
2279
+ ) {
2280
+ retained += 1;
2281
+ issues.push({
2282
+ coordinate: claim.realmKey,
2283
+ kind: "cleanup-unsafe",
2284
+ });
2285
+ continue;
2286
+ }
2287
+ const recovery = hasPublishedLocalCliResource
2288
+ ? recoverSameBootCleanupUnsafe(
2289
+ entry,
2290
+ environment,
2291
+ inspectOwner,
2292
+ )
2293
+ : claim.owner.bootId !== currentBootId
2294
+ ? "proof-unavailable"
2295
+ : await recoverBrowserCleanupUnsafe(
2296
+ entry,
2297
+ environment,
2298
+ inspectOwner,
2299
+ browserLifecycle,
2300
+ );
2301
+ if (recovery === "repaired") {
2302
+ repaired += 1;
2303
+ continue;
2304
+ }
2305
+ retained += 1;
2306
+ issues.push({
2307
+ coordinate: claim.realmKey,
2308
+ kind: recovery === "owner-unknown"
2309
+ ? "owner-unknown"
2310
+ : recovery === "live-owner"
2311
+ ? "resource-active"
2312
+ : recovery === "recovery-active"
2313
+ ? "recovery-active"
2314
+ : recovery === "artifact-conflict"
2315
+ || recovery === "claim-conflict"
2316
+ ? "recovery-conflict"
2317
+ : "cleanup-unsafe",
2318
+ });
2319
+ continue;
2320
+ }
2321
+ if (!repairable) continue;
2322
+ if (removePrivateStateFileIfUnchanged(
2323
+ pathFor(claim.realmKey, environment),
2324
+ { expectedCurrentContentSha256: entry.contentSha256 },
2325
+ environment,
2326
+ )) {
2327
+ repaired += 1;
2328
+ } else {
2329
+ retained += 1;
2330
+ issues.push({
2331
+ coordinate: claim.realmKey,
2332
+ kind: "recovery-conflict",
2333
+ });
2334
+ }
2335
+ }
2336
+ return Object.freeze({
2337
+ scanned: entries.length,
2338
+ repaired,
2339
+ active,
2340
+ retained,
2341
+ invalid,
2342
+ issues: Object.freeze(issues),
2343
+ });
2344
+ }