@legionworks/facet 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/LICENSE-MIT +23 -0
  3. package/README.md +158 -0
  4. package/dist/gallery/chunk-4g8rem85.css +656 -0
  5. package/dist/gallery/chunk-c6s9k9ty.js +5970 -0
  6. package/dist/gallery/frame/artifact.css +8424 -0
  7. package/dist/gallery/frame/chunks/abnfDiagram-N423BO3Z-jj74szbm.js +155 -0
  8. package/dist/gallery/frame/chunks/architecture-TIHT7OUA-cvdjn7dt.js +10 -0
  9. package/dist/gallery/frame/chunks/architectureDiagram-T3A2C74G-n4z77eg6.js +8675 -0
  10. package/dist/gallery/frame/chunks/blockDiagram-VBNYF7ZC-7xsqzrd1.js +3529 -0
  11. package/dist/gallery/frame/chunks/c4Diagram-5PPSVZJV-z2zjs7wx.js +2413 -0
  12. package/dist/gallery/frame/chunks/classDiagram-JCYQIIEL-ve8rngqp.js +47 -0
  13. package/dist/gallery/frame/chunks/classDiagram-v2-OCEON4UE-8cfaxb8z.js +47 -0
  14. package/dist/gallery/frame/chunks/cose-bilkent-JH36ORCC-sn92qexx.js +4822 -0
  15. package/dist/gallery/frame/chunks/cynefin-VYW2F7L2-tqjv913d.js +10 -0
  16. package/dist/gallery/frame/chunks/cynefinDiagram-MW4NZA55-c1c0dj12.js +500 -0
  17. package/dist/gallery/frame/chunks/dagre-VZM6K2ZE-7tyg4yt2.js +476 -0
  18. package/dist/gallery/frame/chunks/diagram-7IWD3JNH-y4eng9sc.js +526 -0
  19. package/dist/gallery/frame/chunks/diagram-B4RE2ZJO-rn3p7j5h.js +673 -0
  20. package/dist/gallery/frame/chunks/diagram-LBJQPF4R-w4a2cm9q.js +255 -0
  21. package/dist/gallery/frame/chunks/diagram-Q27KOJAE-aqc2v7wy.js +575 -0
  22. package/dist/gallery/frame/chunks/diagram-UB23O5K3-xncy8nyd.js +355 -0
  23. package/dist/gallery/frame/chunks/ebnfDiagram-BXEA7PRR-acwgxz16.js +175 -0
  24. package/dist/gallery/frame/chunks/erDiagram-JOGREHBK-mzp5nswt.js +1332 -0
  25. package/dist/gallery/frame/chunks/eventmodeling-45OFAUF4-sg07qr0c.js +10 -0
  26. package/dist/gallery/frame/chunks/flowDiagram-UKHOOZJN-2e5ygs44.js +29 -0
  27. package/dist/gallery/frame/chunks/ganttDiagram-PKOTCBZU-p2hhh7hr.js +2620 -0
  28. package/dist/gallery/frame/chunks/gitGraph-TEB2WS4Q-j6kw0naa.js +10 -0
  29. package/dist/gallery/frame/chunks/gitGraphDiagram-DS77QQ5N-4z9z8wjm.js +1347 -0
  30. package/dist/gallery/frame/chunks/info-DKCQHKI2-mpt80jd3.js +10 -0
  31. package/dist/gallery/frame/chunks/infoDiagram-6WML65LV-5rqm3mgg.js +65 -0
  32. package/dist/gallery/frame/chunks/ishikawaDiagram-WSZJBQD7-b51z96jy.js +975 -0
  33. package/dist/gallery/frame/chunks/journeyDiagram-NVQOT4AX-9jbfp0pk.js +1272 -0
  34. package/dist/gallery/frame/chunks/kanban-definition-27J2QSJJ-4x0vx4se.js +1116 -0
  35. package/dist/gallery/frame/chunks/katex-xfgk95tv.js +14034 -0
  36. package/dist/gallery/frame/chunks/markdown-1s5vwkf2.js +9 -0
  37. package/dist/gallery/frame/chunks/markdown-2h6bwsms.js +3239 -0
  38. package/dist/gallery/frame/chunks/markdown-2x113yh8.js +107 -0
  39. package/dist/gallery/frame/chunks/markdown-30b7rwmp.js +1943 -0
  40. package/dist/gallery/frame/chunks/markdown-356bfj4g.js +2031 -0
  41. package/dist/gallery/frame/chunks/markdown-44n9ea69.js +2406 -0
  42. package/dist/gallery/frame/chunks/markdown-56enrf2r.js +39 -0
  43. package/dist/gallery/frame/chunks/markdown-5avymm8t.js +725 -0
  44. package/dist/gallery/frame/chunks/markdown-6r4svars.js +33 -0
  45. package/dist/gallery/frame/chunks/markdown-6zz8efx2.js +24 -0
  46. package/dist/gallery/frame/chunks/markdown-7jp6prnb.js +19 -0
  47. package/dist/gallery/frame/chunks/markdown-809k7q41.js +55 -0
  48. package/dist/gallery/frame/chunks/markdown-82xmzhzd.js +1076 -0
  49. package/dist/gallery/frame/chunks/markdown-92n37gd4.js +7627 -0
  50. package/dist/gallery/frame/chunks/markdown-ag2sssf8.js +36 -0
  51. package/dist/gallery/frame/chunks/markdown-b4s7ntq2.js +19 -0
  52. package/dist/gallery/frame/chunks/markdown-c9k0fvp1.js +100 -0
  53. package/dist/gallery/frame/chunks/markdown-ceefymn8.js +125 -0
  54. package/dist/gallery/frame/chunks/markdown-d1wfbs70.js +243 -0
  55. package/dist/gallery/frame/chunks/markdown-dy5sgjhc.js +11022 -0
  56. package/dist/gallery/frame/chunks/markdown-dz3z5w30.js +30868 -0
  57. package/dist/gallery/frame/chunks/markdown-eg6ngz7y.js +30340 -0
  58. package/dist/gallery/frame/chunks/markdown-ff5wvb43.js +1149 -0
  59. package/dist/gallery/frame/chunks/markdown-ffg06nfz.js +86 -0
  60. package/dist/gallery/frame/chunks/markdown-fvfncadf.js +472 -0
  61. package/dist/gallery/frame/chunks/markdown-gvyszqpv.js +19 -0
  62. package/dist/gallery/frame/chunks/markdown-gx1p0ew0.js +88 -0
  63. package/dist/gallery/frame/chunks/markdown-gxye5frz.js +65 -0
  64. package/dist/gallery/frame/chunks/markdown-h7pzv34e.js +105 -0
  65. package/dist/gallery/frame/chunks/markdown-hab789t9.js +47 -0
  66. package/dist/gallery/frame/chunks/markdown-hjp6sj8t.js +125 -0
  67. package/dist/gallery/frame/chunks/markdown-jka64qgp.js +1993 -0
  68. package/dist/gallery/frame/chunks/markdown-k8xkn8mw.js +56 -0
  69. package/dist/gallery/frame/chunks/markdown-m7frq8x8.js +112 -0
  70. package/dist/gallery/frame/chunks/markdown-p740v4qm.js +36 -0
  71. package/dist/gallery/frame/chunks/markdown-pxfeh231.js +57 -0
  72. package/dist/gallery/frame/chunks/markdown-pzgd8z3s.js +87 -0
  73. package/dist/gallery/frame/chunks/markdown-qx3cngz2.js +1673 -0
  74. package/dist/gallery/frame/chunks/markdown-rawhfvvc.js +85 -0
  75. package/dist/gallery/frame/chunks/markdown-rd8w0ng9.js +49 -0
  76. package/dist/gallery/frame/chunks/markdown-rm2yxfjj.js +2773 -0
  77. package/dist/gallery/frame/chunks/markdown-s1jatrnk.js +993 -0
  78. package/dist/gallery/frame/chunks/markdown-sgfg5vvq.js +59 -0
  79. package/dist/gallery/frame/chunks/markdown-t4ethv9d.js +409 -0
  80. package/dist/gallery/frame/chunks/markdown-tnmabqvz.js +36 -0
  81. package/dist/gallery/frame/chunks/markdown-tvdpppw8.js +5292 -0
  82. package/dist/gallery/frame/chunks/markdown-v0x27twr.js +22 -0
  83. package/dist/gallery/frame/chunks/markdown-v14td68x.js +36 -0
  84. package/dist/gallery/frame/chunks/markdown-w6sgd54z.js +36 -0
  85. package/dist/gallery/frame/chunks/markdown-xw8dsk8b.js +2125 -0
  86. package/dist/gallery/frame/chunks/markdown-y18mve73.js +85 -0
  87. package/dist/gallery/frame/chunks/markdown-yengsms5.js +427 -0
  88. package/dist/gallery/frame/chunks/markdown-z1wc0v0d.js +1826 -0
  89. package/dist/gallery/frame/chunks/markdown-zazx9h4n.js +329 -0
  90. package/dist/gallery/frame/chunks/mermaid-de7p4v0x.js +30 -0
  91. package/dist/gallery/frame/chunks/mindmap-definition-FAOFIHXS-ra44cbem.js +1216 -0
  92. package/dist/gallery/frame/chunks/packet-7NZHBO7P-xppm5913.js +10 -0
  93. package/dist/gallery/frame/chunks/pegDiagram-VL7TDLO6-a2a4wg03.js +163 -0
  94. package/dist/gallery/frame/chunks/pie-RZYD4A2V-hnbxk152.js +10 -0
  95. package/dist/gallery/frame/chunks/pieDiagram-7S7Q4E2Y-f1vxjtmx.js +311 -0
  96. package/dist/gallery/frame/chunks/quadrantDiagram-CIZ2JOQS-ywafa6kh.js +1390 -0
  97. package/dist/gallery/frame/chunks/radar-I7S5WNFK-ewqd27se.js +10 -0
  98. package/dist/gallery/frame/chunks/railroad-3IZDKUUU-5hy4bdx3.js +10 -0
  99. package/dist/gallery/frame/chunks/railroad-abnf-AHOZXSZD-bh5sxz0d.js +10 -0
  100. package/dist/gallery/frame/chunks/railroad-ebnf-EBAXGLYW-6b9c3zeh.js +10 -0
  101. package/dist/gallery/frame/chunks/railroad-peg-LSFZ7HO6-avc8v39s.js +10 -0
  102. package/dist/gallery/frame/chunks/railroadDiagram-AXF67PYL-qc61wax4.js +131 -0
  103. package/dist/gallery/frame/chunks/requirementDiagram-LRYGKXZP-9bs4786c.js +1295 -0
  104. package/dist/gallery/frame/chunks/sankeyDiagram-W5VNT64P-awt1em8b.js +1339 -0
  105. package/dist/gallery/frame/chunks/sequenceDiagram-SI44F4Z6-qaf9mz3t.js +4324 -0
  106. package/dist/gallery/frame/chunks/sizeCapture-X5ZJPWSS-9fq685xd.js +64 -0
  107. package/dist/gallery/frame/chunks/stateDiagram-OKZ733FA-vx7a7hh0.js +456 -0
  108. package/dist/gallery/frame/chunks/stateDiagram-v2-UEYNNEHI-x4m6ws5t.js +46 -0
  109. package/dist/gallery/frame/chunks/swimlanes-SLNWSIFB-e4mxm1gh.js +8287 -0
  110. package/dist/gallery/frame/chunks/swimlanesDiagram-ULZ7WXOC-0h93e2qq.js +42 -0
  111. package/dist/gallery/frame/chunks/timeline-definition-Z64GVDOM-wp0bhbzg.js +1549 -0
  112. package/dist/gallery/frame/chunks/treeView-QDETBFTQ-hcx7n0vt.js +10 -0
  113. package/dist/gallery/frame/chunks/treemap-6X3UGDF4-av02skdw.js +10 -0
  114. package/dist/gallery/frame/chunks/vennDiagram-T6HMQDX7-pmvk1p7n.js +2566 -0
  115. package/dist/gallery/frame/chunks/wardley-OPB4EBWU-njsp6e40.js +10 -0
  116. package/dist/gallery/frame/chunks/wardleyDiagram-T6FBY63Y-eggmnv4z.js +979 -0
  117. package/dist/gallery/frame/chunks/xychartDiagram-ELKLHX3M-q40tnpe5.js +1954 -0
  118. package/dist/gallery/frame/frame.css +183 -0
  119. package/dist/gallery/frame/runtime/chart.js +42744 -0
  120. package/dist/gallery/frame/runtime/html.js +14 -0
  121. package/dist/gallery/frame/runtime/markdown.js +1403 -0
  122. package/dist/gallery/frame/runtime/mermaid.js +32 -0
  123. package/dist/gallery/frame/runtime/svg.js +14 -0
  124. package/dist/gallery/frame/runtime/tsx.js +52 -0
  125. package/dist/gallery/index.html +104 -0
  126. package/docs/reference/cli.md +163 -0
  127. package/docs/reference/export.md +106 -0
  128. package/docs/reference/html.md +215 -0
  129. package/docs/reference/http.md +85 -0
  130. package/docs/reference/mcp.md +78 -0
  131. package/docs/reference/security.md +119 -0
  132. package/docs/reference/storage.md +93 -0
  133. package/docs/reference/tsx.md +101 -0
  134. package/docs/reference/validation.md +212 -0
  135. package/package.json +89 -0
  136. package/scripts/build-gallery.ts +127 -0
  137. package/scripts/launch-netns.sh +67 -0
  138. package/skills/facet/SKILL.md +100 -0
  139. package/src/cli/client.ts +311 -0
  140. package/src/cli/commands/create.ts +49 -0
  141. package/src/cli/commands/doctor.ts +249 -0
  142. package/src/cli/commands/export.ts +179 -0
  143. package/src/cli/commands/instantiate.ts +35 -0
  144. package/src/cli/commands/list.ts +49 -0
  145. package/src/cli/commands/open.ts +75 -0
  146. package/src/cli/commands/pin.ts +39 -0
  147. package/src/cli/commands/promote.ts +49 -0
  148. package/src/cli/commands/publish.ts +122 -0
  149. package/src/cli/commands/read-back.ts +56 -0
  150. package/src/cli/commands/status.ts +114 -0
  151. package/src/cli/commands/watch.ts +146 -0
  152. package/src/cli/main.ts +522 -0
  153. package/src/cli/output.ts +106 -0
  154. package/src/cli/parser.ts +376 -0
  155. package/src/cli/presenter.ts +212 -0
  156. package/src/cli/service-metadata.ts +134 -0
  157. package/src/cli/spawn-service.ts +326 -0
  158. package/src/css.d.ts +4 -0
  159. package/src/gallery-web/app.ts +1581 -0
  160. package/src/gallery-web/export-menu.ts +121 -0
  161. package/src/gallery-web/export.ts +112 -0
  162. package/src/gallery-web/favicon.ts +54 -0
  163. package/src/gallery-web/frame/entries/chart.ts +6 -0
  164. package/src/gallery-web/frame/entries/html.ts +6 -0
  165. package/src/gallery-web/frame/entries/markdown.ts +6 -0
  166. package/src/gallery-web/frame/entries/mermaid.ts +6 -0
  167. package/src/gallery-web/frame/entries/svg.ts +6 -0
  168. package/src/gallery-web/frame/entries/tsx.ts +6 -0
  169. package/src/gallery-web/frame/frame-payload.ts +91 -0
  170. package/src/gallery-web/frame/renderer-validation.ts +13 -0
  171. package/src/gallery-web/frame/renderers/chart.ts +182 -0
  172. package/src/gallery-web/frame/renderers/dompurify-shim.ts +112 -0
  173. package/src/gallery-web/frame/renderers/html.ts +48 -0
  174. package/src/gallery-web/frame/renderers/markdown.ts +81 -0
  175. package/src/gallery-web/frame/renderers/mermaid.ts +120 -0
  176. package/src/gallery-web/frame/renderers/registry.ts +260 -0
  177. package/src/gallery-web/frame/renderers/svg.ts +393 -0
  178. package/src/gallery-web/frame/renderers/tsx.ts +61 -0
  179. package/src/gallery-web/frame/runtime.ts +501 -0
  180. package/src/gallery-web/frame/styles/artifact.css +8424 -0
  181. package/src/gallery-web/frame/styles/frame.css +183 -0
  182. package/src/gallery-web/frame/styles/html-source.css +23 -0
  183. package/src/gallery-web/frame/view-box.ts +33 -0
  184. package/src/gallery-web/frame-html.ts +72 -0
  185. package/src/gallery-web/index.html +104 -0
  186. package/src/gallery-web/session.ts +107 -0
  187. package/src/gallery-web/sse-client.ts +212 -0
  188. package/src/gallery-web/styles/app.css +420 -0
  189. package/src/gallery-web/styles/tokens.css +112 -0
  190. package/src/gallery-web/styles/verdict.css +186 -0
  191. package/src/gallery-web/swap.ts +64 -0
  192. package/src/gallery-web/theme.ts +40 -0
  193. package/src/gallery-web/view-state.ts +93 -0
  194. package/src/harness-adapters/claude-code/README.md +10 -0
  195. package/src/harness-adapters/claude-code/facet.sh +4 -0
  196. package/src/harness-adapters/codex/README.md +10 -0
  197. package/src/harness-adapters/codex/facet.sh +4 -0
  198. package/src/harness-adapters/mcp/README.md +13 -0
  199. package/src/harness-adapters/mcp/cli-bridge.ts +216 -0
  200. package/src/harness-adapters/mcp/main.ts +15 -0
  201. package/src/harness-adapters/mcp/server.ts +164 -0
  202. package/src/harness-adapters/mcp/tool-schemas.ts +53 -0
  203. package/src/harness-adapters/opencode/README.md +10 -0
  204. package/src/harness-adapters/opencode/facet.sh +4 -0
  205. package/src/runtime/compiled-entrypoints.ts +69 -0
  206. package/src/service/dispatcher.ts +717 -0
  207. package/src/service/export.ts +218 -0
  208. package/src/service/http-utils.ts +55 -0
  209. package/src/service/lexical/expectations.ts +210 -0
  210. package/src/service/lifecycle/idle-controller.ts +125 -0
  211. package/src/service/lifecycle/orphan-cleanup.ts +128 -0
  212. package/src/service/lifecycle/process-lock.ts +235 -0
  213. package/src/service/main.ts +136 -0
  214. package/src/service/process.ts +240 -0
  215. package/src/service/router-guards.ts +239 -0
  216. package/src/service/router.ts +627 -0
  217. package/src/service/security/auth.ts +168 -0
  218. package/src/service/security/host-origin.ts +138 -0
  219. package/src/service/security/http-guards.ts +13 -0
  220. package/src/service/security/leases.ts +182 -0
  221. package/src/service/security/token-store.ts +138 -0
  222. package/src/service/server.ts +356 -0
  223. package/src/service/store/database.ts +61 -0
  224. package/src/service/store/evidence-retention.ts +234 -0
  225. package/src/service/store/migrations.ts +167 -0
  226. package/src/service/store/repository-lifecycle.ts +121 -0
  227. package/src/service/store/repository.ts +732 -0
  228. package/src/service/store/schema.ts +218 -0
  229. package/src/service/stored-verdict.ts +97 -0
  230. package/src/service/stream.ts +301 -0
  231. package/src/service/verdict-enrichment.ts +45 -0
  232. package/src/shared/build/frame-bundle-plugins.ts +61 -0
  233. package/src/shared/build-mode.ts +28 -0
  234. package/src/shared/config/evidence-read.ts +98 -0
  235. package/src/shared/config/limits.ts +56 -0
  236. package/src/shared/config/paths.ts +96 -0
  237. package/src/shared/contracts/artifact-types.ts +2 -0
  238. package/src/shared/contracts/artifact.ts +98 -0
  239. package/src/shared/contracts/commands/_shared.ts +59 -0
  240. package/src/shared/contracts/commands/guards.ts +86 -0
  241. package/src/shared/contracts/commands/index.ts +204 -0
  242. package/src/shared/contracts/commands/names.ts +35 -0
  243. package/src/shared/contracts/commands/requests.ts +149 -0
  244. package/src/shared/contracts/commands/results.ts +200 -0
  245. package/src/shared/contracts/envelope.ts +140 -0
  246. package/src/shared/contracts/events.ts +41 -0
  247. package/src/shared/contracts/observed-counts.ts +19 -0
  248. package/src/shared/contracts/renderers.ts +6 -0
  249. package/src/shared/contracts/validation.ts +296 -0
  250. package/src/shared/errors/facet-error.ts +127 -0
  251. package/src/shared/errors/store-error.ts +68 -0
  252. package/src/shared/evidence-image.ts +37 -0
  253. package/src/shared/export.ts +31 -0
  254. package/src/shared/html/policy.ts +108 -0
  255. package/src/shared/html/style-vocabulary.ts +352 -0
  256. package/src/shared/logging/logger.ts +108 -0
  257. package/src/shared/logging/redact.ts +49 -0
  258. package/src/shared/security/frozen-csp.ts +13 -0
  259. package/src/shared/storage-version.ts +2 -0
  260. package/src/shared/tsx/execution.ts +29 -0
  261. package/src/shared/tsx/import-policy.ts +94 -0
  262. package/src/shared/util/dir-permissions.ts +94 -0
  263. package/src/shared/util/mermaid-nodes.ts +230 -0
  264. package/src/shared/util/process.ts +67 -0
  265. package/src/shared/util/time.ts +21 -0
  266. package/src/shared/version.ts +3 -0
  267. package/src/validation/sandbox/limits.ts +41 -0
  268. package/src/validation/sandbox/netns.ts +177 -0
  269. package/src/validation/tier0/chart.ts +177 -0
  270. package/src/validation/tier0/dom-shim.ts +54 -0
  271. package/src/validation/tier0/html.ts +408 -0
  272. package/src/validation/tier0/markdown.ts +285 -0
  273. package/src/validation/tier0/mermaid.ts +98 -0
  274. package/src/validation/tier0/runner.ts +504 -0
  275. package/src/validation/tier0/svg.ts +321 -0
  276. package/src/validation/tier0/tsx/allowlist-resolver.ts +77 -0
  277. package/src/validation/tier0/tsx/ast-policy-capabilities.ts +238 -0
  278. package/src/validation/tier0/tsx/ast-policy-shared.ts +71 -0
  279. package/src/validation/tier0/tsx/ast-policy.ts +181 -0
  280. package/src/validation/tier0/tsx/compiler.ts +156 -0
  281. package/src/validation/tier0/worker-dispatch.ts +211 -0
  282. package/src/validation/tier0/worker-entry.ts +96 -0
  283. package/src/validation/tier0/worker-input.ts +75 -0
  284. package/src/validation/tier1/browser-process.ts +147 -0
  285. package/src/validation/tier1/cdp-pipe.ts +281 -0
  286. package/src/validation/tier1/entries/chart.ts +5 -0
  287. package/src/validation/tier1/entries/html.ts +6 -0
  288. package/src/validation/tier1/entries/markdown.ts +5 -0
  289. package/src/validation/tier1/entries/mermaid.ts +5 -0
  290. package/src/validation/tier1/entries/svg.ts +5 -0
  291. package/src/validation/tier1/entries/tsx.ts +5 -0
  292. package/src/validation/tier1/frame-target.ts +162 -0
  293. package/src/validation/tier1/harness-entry.ts +261 -0
  294. package/src/validation/tier1/harness.ts +215 -0
  295. package/src/validation/tier1/isolated-probe.ts +94 -0
  296. package/src/validation/tier1/launcher.ts +177 -0
  297. package/src/validation/tier1/limits.ts +80 -0
  298. package/src/validation/tier1/nonce.ts +17 -0
  299. package/src/validation/tier1/protocol-probe.ts +519 -0
  300. package/src/validation/tier1/runner.ts +1122 -0
  301. package/src/validation/tier1/verdict.ts +246 -0
  302. package/src/validation/tier1/webp.ts +69 -0
  303. package/templates/README.md +88 -0
  304. package/templates/bar-compare.vl.json +63 -0
  305. package/templates/capacity-report.tsx +102 -0
  306. package/templates/decision-record.md +24 -0
  307. package/templates/deployment-state.mmd +40 -0
  308. package/templates/exemplar.md +91 -0
  309. package/templates/facet-story.tsx +799 -0
  310. package/templates/fleet-dashboard.html +141 -0
  311. package/templates/html-release-ledger.html +91 -0
  312. package/templates/html-status-report.html +114 -0
  313. package/templates/incident-console.tsx +147 -0
  314. package/templates/legion-boundaries.mmd +23 -0
  315. package/templates/legion-flow.mmd +15 -0
  316. package/templates/legion-sequence.mmd +14 -0
  317. package/templates/legion-state.mmd +7 -0
  318. package/templates/metric-card.svg +29 -0
  319. package/templates/observability-map.svg +101 -0
  320. package/templates/pipeline-audit.md +108 -0
  321. package/templates/release-metrics.vl.json +98 -0
  322. package/templates/service-topology.mmd +47 -0
  323. package/templates/status-report.md +32 -0
  324. package/templates/system-map.svg +53 -0
  325. package/templates/timeseries.vl.json +71 -0
  326. package/templates/tsx-interactive-counter.tsx +19 -0
  327. package/templates/tsx-status-report.tsx +31 -0
@@ -0,0 +1,1122 @@
1
+ /**
2
+ * Tier 1 runner — the parent-side orchestrator.
3
+ *
4
+ * Spawns an ephemeral netns'd `chrome-headless-shell`, drives the
5
+ * pre-armed probe sequence against the opaque `about:srcdoc` child
6
+ * frame, and binds the final verdict to the (artifactId, revisionSha)
7
+ * the caller supplied.
8
+ *
9
+ * The flow matches §6.1 of the design spike and the harness-spike
10
+ * driver:
11
+ *
12
+ * 1. Resolve the pinned shell + netns wrapper.
13
+ * 2. Launch the browser (CDP pipe, ephemeral 0700 profile).
14
+ * 3. Write the host page to a tmpdir; navigate the browser to it.
15
+ * 4. Resolve the `about:srcdoc` child frame.
16
+ * 5. Create an isolated execution world BEFORE source ingress.
17
+ * 6. Inject the artifact into the iframe via the ingress port.
18
+ * 7. Wait for the trusted `render-complete` control event.
19
+ * 8. Run `DOMSnapshot.captureSnapshot` + `DOM.getDocument` +
20
+ * isolated-world evaluation in that order.
21
+ * 9. Capture the page-shim self-report from the control port.
22
+ * 10. Derive the verdict and bind it to (artifactId, revisionSha).
23
+ * 11. Tear everything down (close session, kill browser, rm tmpdir).
24
+ *
25
+ * System-level failures throw `FacetError` with a typed `tier1_*`
26
+ * code. Verdict-level divergences (`tampered`, `shim_only`, etc.)
27
+ * are returned as a populated `Tier1Result` — never a throw.
28
+ */
29
+
30
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { Buffer } from "node:buffer";
34
+
35
+ import {
36
+ Tier1ResultSchema,
37
+ type ProtocolObservation,
38
+ type InsecureLevel,
39
+ type IsolationProbeResult,
40
+ type ScreenshotError,
41
+ type Tier1Input,
42
+ type Tier1Result,
43
+ } from "../../shared/contracts/validation";
44
+ import type { EvidenceImageFormat } from "../../shared/evidence-image";
45
+ import {
46
+ HTML_OBSERVED_COUNT_KEYS,
47
+ type ObservedCountKey,
48
+ OBSERVED_COUNT_KEYS,
49
+ } from "../../shared/contracts/observed-counts";
50
+ import { FacetError } from "../../shared/errors/facet-error";
51
+ import { probeLauncherAvailability } from "./browser-process";
52
+ import { computeFacetPaths } from "../../shared/config/paths";
53
+ import { ensureOwnerOnlyDirectory } from "../../shared/util/dir-permissions";
54
+
55
+ import { buildHostPage } from "./harness";
56
+ import { PuppeteerTier1Browser, Tier1TransportWedgeError } from "./cdp-pipe";
57
+ import { resolveLauncher } from "./launcher";
58
+ import {
59
+ createIsolatedWorld,
60
+ resolveNestedArtifactFrame,
61
+ resolveSrcdocChildFrame,
62
+ } from "./frame-target";
63
+ import { probeProtocolGetDocument, probeProtocolSnapshot } from "./protocol-probe";
64
+ import { probeIsolatedCounts } from "./isolated-probe";
65
+ import {
66
+ TIER1_ANIMATION_FRAME_COUNT,
67
+ TIER1_ANIMATION_FRAME_INTERVAL_MS,
68
+ TIER1_ANIMATION_WEBP_QUALITIES,
69
+ TIER1_RENDER_BARRIER_MS,
70
+ TIER1_SCREENSHOT_CAPTURE_ATTEMPTS,
71
+ TIER1_SCREENSHOT_CAP_BYTES,
72
+ TIER1_SCREENSHOT_CAPTURE_TIMEOUT_MS,
73
+ TIER1_SCREENSHOT_MAX_AXIS_PX,
74
+ TIER1_SCREENSHOT_MAX_PIXELS,
75
+ TIER1_SCREENSHOT_WEBP_QUALITY,
76
+ TIER1_VIEWPORT_HEIGHT,
77
+ TIER1_VIEWPORT_WIDTH,
78
+ TSX_STABILITY_WINDOW_MS,
79
+ } from "./limits";
80
+ import { encodeAnimatedWebpWithinCap } from "./webp";
81
+ import { type VerifierCdpSession, type VerifierTarget } from "./browser-process";
82
+ import { countsDiffer, deriveVerdict, type PageShim } from "./verdict";
83
+ import { readPidStartTimeTicks } from "../../shared/util/process";
84
+
85
+ /**
86
+ * Hard ceiling for the captured console summary. Real page logs from
87
+ * a hostile artifact can be unbounded; the runner truncates at this
88
+ * boundary so a poison artifact cannot blow out the evidence dir.
89
+ */
90
+ const CONSOLE_SUMMARY_CUP_BYTES = 64 * 1024;
91
+ const RUNTIME_EXCEPTION_CAP = 16;
92
+ const TIER1_TRACE = process.env.FACET_TIER1_TRACE === "1";
93
+
94
+ /** Probe the pinned wrapper and browser before selecting Tier 1. */
95
+ export async function probeTier1Availability(): Promise<IsolationProbeResult> {
96
+ return probeLauncherAvailability();
97
+ }
98
+
99
+ function traceTier1(stage: string, startedAt: number, detail = ""): void {
100
+ if (!TIER1_TRACE) return;
101
+ const suffix = detail.length > 0 ? ` ${detail}` : "";
102
+ process.stderr.write(`[tier1] +${Date.now() - startedAt}ms ${stage}${suffix}\n`);
103
+ }
104
+
105
+ /**
106
+ * The wedge's second face: with the pipe torn down, puppeteer rejects
107
+ * sends with a target/session-closed error instead of pending forever.
108
+ * Same cause, same remedy — relaunch once with a fresh browser.
109
+ */
110
+ function isTransportClosedError(error: unknown): boolean {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return (
113
+ message.includes("Session closed") ||
114
+ message.includes("Target closed") ||
115
+ message.includes("Connection closed")
116
+ );
117
+ }
118
+
119
+ interface ShimCapture {
120
+ readonly pageShim: PageShim | null;
121
+ readonly bootReady: boolean;
122
+ readonly renderComplete: boolean;
123
+ }
124
+
125
+ export interface CapturedEvidenceImage {
126
+ readonly bytes: Buffer;
127
+ readonly format: EvidenceImageFormat;
128
+ }
129
+
130
+ export interface ArtifactCaptureBounds {
131
+ readonly width: number;
132
+ readonly height: number;
133
+ readonly scale: number;
134
+ }
135
+
136
+ interface ArtifactCaptureMeasurement {
137
+ readonly width: number;
138
+ readonly height: number;
139
+ }
140
+
141
+ interface BoundedScreenshotCapture {
142
+ readonly bounds: ArtifactCaptureBounds;
143
+ readonly source: ArtifactCaptureMeasurement;
144
+ }
145
+
146
+ type ScreenshotCapture = (
147
+ session: VerifierCdpSession,
148
+ bounds?: BoundedScreenshotCapture,
149
+ ) => Promise<CapturedEvidenceImage | null>;
150
+
151
+ /** Test-only hook for pinning the screenshot transport without changing verifier logic. */
152
+ export interface Tier1RunnerTestHooks {
153
+ readonly captureScreenshot?: ScreenshotCapture;
154
+ readonly createBrowser?: () => Pick<PuppeteerTier1Browser, "launch">;
155
+ }
156
+
157
+ export function createTier1RunnerForTests(
158
+ hooks: Tier1RunnerTestHooks,
159
+ ): (input: Tier1Input) => Promise<Tier1Result> {
160
+ return async (input) => runTier1WithHooks(input, hooks, 0);
161
+ }
162
+
163
+ /**
164
+ * Drive the verifier end-to-end. Returns the typed `Tier1Result`.
165
+ * Throws `FacetError` only on system-level failures (no shell,
166
+ * netns unavailable, browser died, probe timed out, protocol
167
+ * error); verdict-level divergences are surfaced via the `status`
168
+ * field on the returned result.
169
+ *
170
+ * A wedged CDP transport (browser alive, pipe dead — a launch-time
171
+ * race in the subprocess layer when a browser is spawned immediately
172
+ * after another browser's teardown) is retried ONCE with a fresh
173
+ * browser: it is a property of the launch, not of the artifact, so a
174
+ * relaunch verifies the same bytes.
175
+ */
176
+ export async function runTier1(input: Tier1Input): Promise<Tier1Result> {
177
+ return runTier1WithHooks(input, {}, 0);
178
+ }
179
+
180
+ /** Create a Tier 1 runner using the requested browser isolation level. */
181
+ export function createTier1Runner(
182
+ level: InsecureLevel,
183
+ ): (input: Tier1Input) => Promise<Tier1Result> {
184
+ return (input) => runTier1WithHooks(input, {}, level);
185
+ }
186
+
187
+ async function runTier1WithHooks(
188
+ input: Tier1Input,
189
+ hooks: Tier1RunnerTestHooks,
190
+ level: InsecureLevel,
191
+ ): Promise<Tier1Result> {
192
+ const startedAt = Date.now();
193
+ traceTier1("run:start", startedAt);
194
+ let lastWedge: Tier1TransportWedgeError | undefined;
195
+ for (let attempt = 0; attempt < 2; attempt += 1) {
196
+ try {
197
+ traceTier1(`attempt:${attempt + 1}:start`, startedAt);
198
+ const result = await runTier1Attempt(input, startedAt, hooks, level);
199
+ traceTier1(`attempt:${attempt + 1}:complete`, startedAt, `status=${result.status}`);
200
+ return result;
201
+ } catch (error) {
202
+ if (error instanceof Tier1TransportWedgeError) {
203
+ traceTier1(`attempt:${attempt + 1}:transport-wedge`, startedAt, error.message);
204
+ lastWedge = error;
205
+ continue;
206
+ }
207
+ traceTier1(
208
+ `attempt:${attempt + 1}:error`,
209
+ startedAt,
210
+ error instanceof Error ? error.message : String(error),
211
+ );
212
+ throw error;
213
+ }
214
+ }
215
+ traceTier1("run:transport-error", startedAt);
216
+ throw new FacetError("tier1_protocol_error", lastWedge?.message ?? "tier1 transport wedged", {
217
+ retryable: false,
218
+ });
219
+ }
220
+
221
+ async function runTier1Attempt(
222
+ input: Tier1Input,
223
+ startedAt = Date.now(),
224
+ hooks: Tier1RunnerTestHooks = {},
225
+ level: InsecureLevel = 0,
226
+ ): Promise<Tier1Result> {
227
+ if (!Number.isInteger(input.artifactType === undefined)) {
228
+ void (input.artifactType as unknown);
229
+ }
230
+ const profileDir = mkdtempSync(join(tmpdir(), "facet-tier1-host-"));
231
+ let target: VerifierTarget | undefined;
232
+ let runtimeExceptions: RuntimeExceptionCollector | undefined;
233
+ let targetStartTime = 0;
234
+ let hostHtmlPath: string | undefined;
235
+ const hostDir = mkdtempSync(join(tmpdir(), "facet-tier1-hostdir-"));
236
+ let wedged = false;
237
+ // Per-run evidence directory under the XDG-state evidence root
238
+ // (mode 0700). The dispatcher wires the parent's evidenceRoot in;
239
+ // when omitted the runner falls back to the canonical path so
240
+ // production callers never have to specify it. The mkdir goes
241
+ // through `ensureOwnerOnlyDirectory` so a hostile process umask
242
+ // cannot widen the secret-bearing layout to 0755 — a fix in that
243
+ // helper lands in one place (the service's `evidence-retention.ts`
244
+ // delegates to the same helper).
245
+ const evidenceRoot = input.evidenceDir ?? computeFacetPaths().evidence;
246
+ const runId = crypto.randomUUID();
247
+ const runEvidenceDir = ensureOwnerOnlyDirectory(
248
+ join(evidenceRoot, "tier1", input.revisionSha, runId),
249
+ );
250
+ const consolePath = join(runEvidenceDir, "console.txt");
251
+ const observationPath = join(runEvidenceDir, "protocol-observation.json");
252
+
253
+ try {
254
+ const browser =
255
+ hooks.createBrowser?.() ??
256
+ new PuppeteerTier1Browser({
257
+ launcher: resolveLauncher(level, { version: input.launcherVersion }),
258
+ });
259
+ traceTier1("launch:start", startedAt);
260
+ target = await browser.launch();
261
+ traceTier1("launch:complete", startedAt, `pid=${target.pid}`);
262
+ runtimeExceptions = new RuntimeExceptionCollector(target.session);
263
+ // Snapshot the OS start time NOW so the wedge teardown can confirm
264
+ // the pid still belongs to this browser before signaling it (a
265
+ // dead browser's pid can be reused by an unrelated process).
266
+ targetStartTime = target.startTime;
267
+ hostHtmlPath = join(hostDir, "host.html");
268
+ traceTier1("host-page:start", startedAt);
269
+ const { html } = await buildHostPage(
270
+ input.source,
271
+ "render",
272
+ hostDir,
273
+ input.artifactType,
274
+ input.renderer,
275
+ input.execution ?? "static",
276
+ );
277
+ traceTier1("host-page:complete", startedAt);
278
+ writeFileSync(hostHtmlPath, html, "utf8");
279
+ traceTier1("cdp:enable:start", startedAt);
280
+ await target.session.send("Runtime.enable");
281
+ await target.session.send("Page.enable");
282
+ traceTier1("cdp:enable:complete", startedAt);
283
+ await configureTier1Viewport(target.session);
284
+ traceTier1("viewport:configured", startedAt);
285
+ traceTier1("navigate:start", startedAt);
286
+ await target.session.send("Page.navigate", { url: `file://${hostHtmlPath}` });
287
+ traceTier1("navigate:complete", startedAt);
288
+ // Wait for the host page to settle.
289
+ await waitForBootReady(target, TIER1_RENDER_BARRIER_MS);
290
+ traceTier1("boot-ready:complete", startedAt);
291
+
292
+ traceTier1("frame-resolve:start", startedAt);
293
+ const childFrame = await resolveSrcdocChildFrame(target.session);
294
+ traceTier1("frame-resolve:complete", startedAt);
295
+ const interactiveTsx = input.artifactType === "tsx" && input.execution === "interactive";
296
+ const staticIsolated = interactiveTsx
297
+ ? null
298
+ : await createIsolatedWorld(target.session, childFrame.frameId);
299
+
300
+ // Inject the artifact via the parent page world's transfer (the
301
+ // parent page has the ingress port; the iframe receives it via
302
+ // postMessage handshake).
303
+ traceTier1("deliver:start", startedAt);
304
+ await target.session.send("Runtime.evaluate", {
305
+ expression:
306
+ "(function(){" +
307
+ "var host=window.__facetHostArtifact;" +
308
+ "if(!host){return 'no-host-artifact';}" +
309
+ "host.ingress.postMessage({bytes:host.bytes,mode:host.mode,artifactType:host.artifactType,renderer:host.renderer,execution:host.execution});" +
310
+ "return 'delivered';" +
311
+ "})()",
312
+ returnByValue: true,
313
+ });
314
+ traceTier1("deliver:complete", startedAt);
315
+
316
+ traceTier1("render-complete:wait", startedAt);
317
+ const shim = await waitForRenderComplete(target, TIER1_RENDER_BARRIER_MS);
318
+ traceTier1("render-complete:complete", startedAt, `received=${shim.renderComplete}`);
319
+
320
+ const artifactFrame = interactiveTsx
321
+ ? await resolveNestedArtifactFrame(target.session, childFrame)
322
+ : childFrame;
323
+ const isolated =
324
+ staticIsolated ?? (await createIsolatedWorld(target.session, artifactFrame.frameId));
325
+ const firstObservation = await observeArtifact(
326
+ target.session,
327
+ artifactFrame,
328
+ isolated.executionContextId,
329
+ interactiveTsx ? runtimeExceptions!.errorsForFrame(artifactFrame.frameId) : [],
330
+ );
331
+ const secondObservation = interactiveTsx
332
+ ? await waitForStabilityObservation(
333
+ target.session,
334
+ artifactFrame,
335
+ isolated.executionContextId,
336
+ () => runtimeExceptions!.errorsForFrame(artifactFrame.frameId),
337
+ )
338
+ : firstObservation;
339
+ const protocolObservation = secondObservation.protocol;
340
+ const isolatedObservation = secondObservation.isolated;
341
+ const channelDivergence =
342
+ interactiveTsx && observationsDiverge(firstObservation, secondObservation);
343
+
344
+ const status = deriveVerdict(
345
+ input.lexical,
346
+ protocolObservation,
347
+ isolatedObservation,
348
+ interactiveTsx ? null : shim.pageShim,
349
+ {
350
+ bootReady: shim.bootReady,
351
+ renderComplete: shim.renderComplete,
352
+ interactive: interactiveTsx,
353
+ channelDivergence,
354
+ structureChanged:
355
+ interactiveTsx && countsDiffer(firstObservation.protocol, secondObservation.protocol),
356
+ },
357
+ );
358
+ traceTier1("verdict:complete", startedAt, `status=${status}`);
359
+
360
+ // Capture follows derivation so a transport-only failure cannot rewrite the render verdict.
361
+ const captured = await captureEvidence(
362
+ target,
363
+ {
364
+ input,
365
+ screenshotDir: runEvidenceDir,
366
+ consolePath,
367
+ observationPath,
368
+ firstProtocolObservation: firstObservation.protocol,
369
+ protocolObservation,
370
+ pageShim: shim.pageShim,
371
+ executionContextId: isolated.executionContextId,
372
+ },
373
+ hooks.captureScreenshot,
374
+ );
375
+ traceTier1("evidence:complete", startedAt);
376
+
377
+ const observed = protocolObservation;
378
+ const result: Tier1Result = Tier1ResultSchema.parse({
379
+ tier: 1,
380
+ status,
381
+ artifactId: "tier1-runner",
382
+ revisionSha: input.revisionSha,
383
+ expected: input.lexical,
384
+ observed: {
385
+ rendererRootSvgCount: observed.rendererRootSvgCount,
386
+ graphCount: observed.graphCount,
387
+ mermaidNodeCount: observed.mermaidNodeCount,
388
+ visibleSvgCount: observed.visibleSvgCount,
389
+ viewBoxes: observed.viewBoxes,
390
+ errorCount: observed.errorCount,
391
+ opaqueRegionCount: observed.opaqueRegionCount,
392
+ externalImageCount: observed.externalImageCount,
393
+ ...(observed.html === undefined ? {} : { html: observed.html }),
394
+ discriminativeErrors: observed.discriminativeErrors,
395
+ },
396
+ screenshotPath: captured.screenshotPath,
397
+ ...(captured.screenshotFormat === null
398
+ ? {}
399
+ : { screenshotFormat: captured.screenshotFormat }),
400
+ consolePath: captured.consolePath,
401
+ ...(captured.screenshotError !== null ? { screenshotError: captured.screenshotError } : {}),
402
+ });
403
+ return result;
404
+ } catch (error) {
405
+ if (error instanceof FacetError) throw error;
406
+ if (error instanceof Tier1TransportWedgeError || isTransportClosedError(error)) {
407
+ // Flag before the finally block so teardown kills the wedged
408
+ // browser instead of pending on its dead pipe.
409
+ wedged = true;
410
+ if (error instanceof Tier1TransportWedgeError) throw error;
411
+ const closedMessage = error instanceof Error ? error.message : String(error);
412
+ throw new Tier1TransportWedgeError(closedMessage);
413
+ }
414
+ const message = error instanceof Error ? error.message : String(error);
415
+ if (message.includes("timed out") || message.includes("timeout")) {
416
+ throw new FacetError("tier1_timeout", message, { retryable: false });
417
+ }
418
+ if (message.includes("not found")) {
419
+ throw new FacetError("tier1_launcher_missing", message, { retryable: false });
420
+ }
421
+ if (level === 0 && /unshare|operation not permitted|network namespace|netns/i.test(message)) {
422
+ throw new FacetError("tier1_unavailable", message, { retryable: false });
423
+ }
424
+ throw new FacetError("tier1_protocol_error", message, { retryable: false, cause: error });
425
+ } finally {
426
+ runtimeExceptions?.close();
427
+ if (target !== undefined) {
428
+ if (wedged && target.pid > 0 && readPidStartTimeTicks(target.pid) === targetStartTime) {
429
+ // A wedged transport makes close() pend on the dead pipe;
430
+ // kill the browser first so close only reaps. The start-time
431
+ // check pins the kill to THIS browser — signaling a reused
432
+ // pid (or -1, the whole process group) is not an option.
433
+ try {
434
+ process.kill(target.pid, "SIGKILL");
435
+ } catch {
436
+ // already dead
437
+ }
438
+ }
439
+ await Promise.race([
440
+ target.close().catch(() => {}),
441
+ new Promise<void>((resolve) => setTimeout(resolve, 3_000)),
442
+ ]);
443
+ }
444
+ if (hostHtmlPath !== undefined) {
445
+ const cleanupStartedAt = performance.now();
446
+ traceTier1("cleanup:host-html:start", startedAt);
447
+ try {
448
+ rmSync(hostHtmlPath, { force: true });
449
+ } catch {
450
+ // best-effort
451
+ } finally {
452
+ traceTier1(
453
+ "cleanup:host-html:complete",
454
+ startedAt,
455
+ `durationMs=${Math.round(performance.now() - cleanupStartedAt)}`,
456
+ );
457
+ }
458
+ }
459
+ const hostDirCleanupStartedAt = performance.now();
460
+ traceTier1("cleanup:host-dir:start", startedAt);
461
+ try {
462
+ rmSync(hostDir, { recursive: true, force: true });
463
+ } catch {
464
+ // best-effort
465
+ } finally {
466
+ traceTier1(
467
+ "cleanup:host-dir:complete",
468
+ startedAt,
469
+ `durationMs=${Math.round(performance.now() - hostDirCleanupStartedAt)}`,
470
+ );
471
+ }
472
+ const profileCleanupStartedAt = performance.now();
473
+ traceTier1("cleanup:profile-dir:start", startedAt);
474
+ try {
475
+ rmSync(profileDir, { recursive: true, force: true });
476
+ } catch {
477
+ // best-effort
478
+ } finally {
479
+ traceTier1(
480
+ "cleanup:profile-dir:complete",
481
+ startedAt,
482
+ `durationMs=${Math.round(performance.now() - profileCleanupStartedAt)}`,
483
+ );
484
+ }
485
+ clearTimeout(undefined as unknown as NodeJS.Timeout);
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Wait for the harness to emit `boot-ready` on the control port.
491
+ * The verifier reads the control port via a Runtime.evaluate that
492
+ * drains the parent page's recorded events; the host page keeps the
493
+ * shim events in a JS array the verifier can inspect.
494
+ */
495
+ async function waitForBootReady(target: VerifierTarget, timeoutMs: number): Promise<void> {
496
+ const deadline = Date.now() + timeoutMs;
497
+ while (Date.now() < deadline) {
498
+ const result = (await target.session.send("Runtime.evaluate", {
499
+ expression: "(function(){return JSON.stringify(window.__facetShimEvents || []);})()",
500
+ returnByValue: true,
501
+ })) as { result: { value: string } };
502
+ try {
503
+ const events = JSON.parse(result.result.value) as { type?: string }[];
504
+ if (events.some((event) => event.type === "boot-ready")) return;
505
+ } catch {
506
+ // ignore parse errors
507
+ }
508
+ await new Promise((resolve) => setTimeout(resolve, 50));
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Wait for the harness to emit `render-complete` and capture the
514
+ * page shim's self-report.
515
+ */
516
+ async function waitForRenderComplete(
517
+ target: VerifierTarget,
518
+ timeoutMs: number,
519
+ ): Promise<ShimCapture> {
520
+ const deadline = Date.now() + timeoutMs;
521
+ while (Date.now() < deadline) {
522
+ const result = (await target.session.send("Runtime.evaluate", {
523
+ expression:
524
+ "(function(){" +
525
+ "var events = window.__facetShimEvents || [];" +
526
+ "var bootReady = events.some(function(e){return e.type==='boot-ready';});" +
527
+ "var renderEvent = events.find(function(e){return e.type==='render-complete';});" +
528
+ "return JSON.stringify({bootReady:bootReady, renderEvent:renderEvent||null});" +
529
+ "})()",
530
+ returnByValue: true,
531
+ })) as { result: { value: string } };
532
+ let parsed: { bootReady: boolean; renderEvent: { observed?: PageShim } | null };
533
+ try {
534
+ parsed = JSON.parse(result.result.value);
535
+ } catch {
536
+ await new Promise((resolve) => setTimeout(resolve, 50));
537
+ continue;
538
+ }
539
+ if (parsed.renderEvent !== null && parsed.renderEvent !== undefined) {
540
+ return {
541
+ bootReady: parsed.bootReady,
542
+ renderComplete: true,
543
+ pageShim: parsed.renderEvent.observed ?? null,
544
+ };
545
+ }
546
+ await new Promise((resolve) => setTimeout(resolve, 50));
547
+ }
548
+ return { bootReady: false, renderComplete: false, pageShim: null };
549
+ }
550
+
551
+ /**
552
+ * Combine `DOMSnapshot` and `DOM.getDocument` into a single
553
+ * authoritative observation. When the two channels disagree on
554
+ * counts, the protocol layer reports the disagreement to the
555
+ * verdict via the `discriminativeErrors` field rather than picking
556
+ * one arbitrarily — the verdict layer treats any non-empty
557
+ * discriminativeErrors as `error`.
558
+ */
559
+ function mergeProtocol(
560
+ snapshot: ProtocolObservation,
561
+ getDocument: ProtocolObservation,
562
+ runtimeErrors: readonly { readonly code: string; readonly message: string }[] = [],
563
+ ): ProtocolObservation {
564
+ const errors = [...snapshot.discriminativeErrors, ...runtimeErrors];
565
+ const counts = OBSERVED_COUNT_KEYS.filter((key) => key !== "externalImageCount").map(
566
+ (key) => [key.replace(/Count$/, ""), snapshot[key], getDocument[key]] as const,
567
+ );
568
+ for (const [name, fromSnapshot, fromDocument] of counts) {
569
+ if (fromSnapshot === fromDocument) continue;
570
+ errors.push({
571
+ code: "protocol_divergence",
572
+ message: `DOMSnapshot.${name}=${fromSnapshot} vs DOM.getDocument.${name}=${fromDocument}`,
573
+ });
574
+ }
575
+ if (snapshot.errorCount !== getDocument.errorCount) {
576
+ errors.push({
577
+ code: "protocol_divergence",
578
+ message: `DOMSnapshot.errors=${snapshot.errorCount} vs DOM.getDocument.errors=${getDocument.errorCount}`,
579
+ });
580
+ }
581
+ // The top-level external-image count is compared below, matching the
582
+ // established protocol divergence behavior without double-reporting it.
583
+ const htmlKeys = HTML_OBSERVED_COUNT_KEYS.filter((key) => key !== "externalImageCount");
584
+ if (snapshot.html === undefined || getDocument.html === undefined) {
585
+ if (snapshot.html !== getDocument.html) {
586
+ errors.push({
587
+ code: "protocol_divergence",
588
+ message: "DOMSnapshot.html presence differs from DOM.getDocument.html",
589
+ });
590
+ }
591
+ } else {
592
+ for (const key of htmlKeys) {
593
+ if (snapshot.html[key] !== getDocument.html[key]) {
594
+ errors.push({
595
+ code: "protocol_divergence",
596
+ message: `DOMSnapshot.html.${key}=${snapshot.html[key]} vs DOM.getDocument.html.${key}=${getDocument.html[key]}`,
597
+ });
598
+ }
599
+ }
600
+ }
601
+ if (snapshot.externalImageCount !== getDocument.externalImageCount) {
602
+ errors.push({
603
+ code: "protocol_divergence",
604
+ message: `DOMSnapshot.externalImageCount=${snapshot.externalImageCount} vs DOM.getDocument.externalImageCount=${getDocument.externalImageCount}`,
605
+ });
606
+ }
607
+ const observedCounts = Object.fromEntries(
608
+ OBSERVED_COUNT_KEYS.map((key) => [key, snapshot[key]]),
609
+ ) as Pick<ProtocolObservation, ObservedCountKey>;
610
+ return {
611
+ ...observedCounts,
612
+ viewBoxes: snapshot.viewBoxes,
613
+ errorCount: snapshot.errorCount,
614
+ ...(snapshot.html === undefined ? {} : { html: snapshot.html }),
615
+ discriminativeErrors: errors,
616
+ };
617
+ }
618
+
619
+ export interface ArtifactObservation {
620
+ readonly protocol: ProtocolObservation;
621
+ readonly isolated: ProtocolObservation | null;
622
+ }
623
+
624
+ interface RuntimeExceptionDetails {
625
+ readonly executionContextId?: number;
626
+ readonly text?: string;
627
+ readonly exception?: {
628
+ readonly description?: string;
629
+ readonly value?: unknown;
630
+ };
631
+ }
632
+
633
+ interface RuntimeExecutionContextCreated {
634
+ readonly context?: {
635
+ readonly id?: number;
636
+ readonly auxData?: { readonly frameId?: string };
637
+ };
638
+ }
639
+
640
+ interface RuntimeExceptionThrown {
641
+ readonly exceptionDetails?: RuntimeExceptionDetails;
642
+ }
643
+
644
+ /**
645
+ * Runtime exceptions are protocol authority: the page cannot erase an event
646
+ * that CDP delivered before either DOM observation runs.
647
+ */
648
+ export class RuntimeExceptionCollector {
649
+ private readonly frameByExecutionContext = new Map<number, string>();
650
+ private readonly exceptions: Array<{
651
+ readonly executionContextId: number;
652
+ readonly message: string;
653
+ }> = [];
654
+ private readonly onContextCreated = (params: unknown): void => {
655
+ const created = params as RuntimeExecutionContextCreated;
656
+ const id = created.context?.id;
657
+ const frameId = created.context?.auxData?.frameId;
658
+ if (typeof id === "number" && typeof frameId === "string") {
659
+ this.frameByExecutionContext.set(id, frameId);
660
+ }
661
+ };
662
+ private readonly onExceptionThrown = (params: unknown): void => {
663
+ if (this.exceptions.length >= RUNTIME_EXCEPTION_CAP) return;
664
+ const details = (params as RuntimeExceptionThrown).exceptionDetails;
665
+ const executionContextId = details?.executionContextId;
666
+ if (typeof executionContextId !== "number") return;
667
+ const message = runtimeExceptionMessage(details);
668
+ this.exceptions.push({ executionContextId, message });
669
+ };
670
+
671
+ constructor(private readonly session: VerifierCdpSession) {
672
+ session.on("Runtime.executionContextCreated", this.onContextCreated);
673
+ session.on("Runtime.exceptionThrown", this.onExceptionThrown);
674
+ }
675
+
676
+ errorsForFrame(frameId: string): readonly { readonly code: string; readonly message: string }[] {
677
+ return this.exceptions
678
+ .filter((entry) => this.frameByExecutionContext.get(entry.executionContextId) === frameId)
679
+ .map((entry) => ({ code: "runtime_exception", message: entry.message }));
680
+ }
681
+
682
+ close(): void {
683
+ this.session.off("Runtime.executionContextCreated", this.onContextCreated);
684
+ this.session.off("Runtime.exceptionThrown", this.onExceptionThrown);
685
+ }
686
+ }
687
+
688
+ function runtimeExceptionMessage(details: RuntimeExceptionDetails | undefined): string {
689
+ if (typeof details?.exception?.description === "string") return details.exception.description;
690
+ if (typeof details?.exception?.value === "string") return details.exception.value;
691
+ if (typeof details?.text === "string") return details.text;
692
+ return "runtime exception";
693
+ }
694
+
695
+ export function authorityChannelsDiverge(
696
+ protocol: ProtocolObservation,
697
+ isolated: ProtocolObservation | null,
698
+ ): boolean {
699
+ return (
700
+ protocol.discriminativeErrors.some((error) => error.code === "protocol_divergence") ||
701
+ (isolated !== null && countsDiffer(isolated, protocol))
702
+ );
703
+ }
704
+
705
+ export function observationsDiverge(
706
+ first: ArtifactObservation,
707
+ second: ArtifactObservation,
708
+ ): boolean {
709
+ return (
710
+ authorityChannelsDiverge(first.protocol, first.isolated) ||
711
+ authorityChannelsDiverge(second.protocol, second.isolated)
712
+ );
713
+ }
714
+
715
+ async function observeArtifact(
716
+ session: VerifierCdpSession,
717
+ frame: { readonly frameId: string; readonly url: string },
718
+ executionContextId: number,
719
+ runtimeErrors: readonly { readonly code: string; readonly message: string }[],
720
+ ): Promise<ArtifactObservation> {
721
+ const snapshot = await probeProtocolSnapshot(session, frame);
722
+ const document = await probeProtocolGetDocument(session, frame);
723
+ const isolated = await probeIsolatedCounts(session, executionContextId);
724
+ return { protocol: mergeProtocol(snapshot, document, runtimeErrors), isolated };
725
+ }
726
+
727
+ async function waitForStabilityObservation(
728
+ session: VerifierCdpSession,
729
+ frame: { readonly frameId: string; readonly url: string },
730
+ executionContextId: number,
731
+ runtimeErrors: () => readonly { readonly code: string; readonly message: string }[],
732
+ ): Promise<ArtifactObservation> {
733
+ await Bun.sleep(TSX_STABILITY_WINDOW_MS);
734
+ return observeArtifact(session, frame, executionContextId, runtimeErrors());
735
+ }
736
+
737
+ interface EvidenceCapture {
738
+ readonly screenshotPath: string | null;
739
+ readonly screenshotFormat: EvidenceImageFormat | null;
740
+ readonly screenshotError: ScreenshotError | null;
741
+ /**
742
+ * Console summary path. The bounded console write is pure
743
+ * filesystem IO and always succeeds, so this field is non-nullable
744
+ * — a transport-wedged browser can lose its screenshot but not its
745
+ * console summary (the per-run directory is already on disk by the
746
+ * time captureEvidence runs).
747
+ */
748
+ readonly consolePath: string;
749
+ }
750
+
751
+ /** Apply the pinned Tier 1 viewport before artifact observation. */
752
+ export async function configureTier1Viewport(session: VerifierCdpSession): Promise<void> {
753
+ return configureTier1ViewportBounds(session, {
754
+ width: TIER1_VIEWPORT_WIDTH,
755
+ height: TIER1_VIEWPORT_HEIGHT,
756
+ });
757
+ }
758
+
759
+ async function configureTier1ViewportBounds(
760
+ session: VerifierCdpSession,
761
+ bounds: ArtifactCaptureMeasurement,
762
+ ): Promise<void> {
763
+ await session.send("Emulation.setDeviceMetricsOverride", {
764
+ width: bounds.width,
765
+ height: bounds.height,
766
+ deviceScaleFactor: 1,
767
+ mobile: false,
768
+ });
769
+ }
770
+
771
+ export function boundCaptureSize(input: {
772
+ readonly width: number;
773
+ readonly height: number;
774
+ }): ArtifactCaptureBounds {
775
+ if (
776
+ !Number.isFinite(input.width) ||
777
+ !Number.isFinite(input.height) ||
778
+ input.width <= 0 ||
779
+ input.height <= 0
780
+ )
781
+ throw new Error("artifact capture bounds are invalid");
782
+ const scale = Math.min(
783
+ 1,
784
+ TIER1_SCREENSHOT_MAX_AXIS_PX / input.width,
785
+ TIER1_SCREENSHOT_MAX_AXIS_PX / input.height,
786
+ Math.sqrt(TIER1_SCREENSHOT_MAX_PIXELS / (input.width * input.height)),
787
+ );
788
+ return {
789
+ width: Math.max(1, Math.floor(input.width * scale)),
790
+ height: Math.max(1, Math.floor(input.height * scale)),
791
+ scale,
792
+ };
793
+ }
794
+
795
+ export async function measureArtifactCaptureBounds(
796
+ session: VerifierCdpSession,
797
+ executionContextId: number,
798
+ ): Promise<ArtifactCaptureBounds> {
799
+ return boundCaptureSize(await measureArtifactCaptureSize(session, executionContextId));
800
+ }
801
+
802
+ async function measureArtifactCaptureSize(
803
+ session: VerifierCdpSession,
804
+ executionContextId: number,
805
+ ): Promise<ArtifactCaptureMeasurement> {
806
+ const measured = (await session.send("Runtime.evaluate", {
807
+ contextId: executionContextId,
808
+ returnByValue: true,
809
+ expression: [
810
+ "(function(){",
811
+ " var container=document.getElementById('artifact');",
812
+ " if(!container) return null;",
813
+ " var containerRect=container.getBoundingClientRect();",
814
+ " var child=container.firstElementChild;",
815
+ " var childRect=child ? child.getBoundingClientRect() : containerRect;",
816
+ " return {",
817
+ " width:Math.max(container.scrollWidth,childRect.right-containerRect.left),",
818
+ " height:Math.max(container.scrollHeight,childRect.bottom-containerRect.top)",
819
+ " };",
820
+ "})()",
821
+ ].join("\n"),
822
+ })) as { result: { value?: { width?: unknown; height?: unknown } | null } };
823
+ const value = measured.result.value;
824
+ if (
825
+ value === null ||
826
+ value === undefined ||
827
+ typeof value.width !== "number" ||
828
+ typeof value.height !== "number"
829
+ )
830
+ throw new Error("artifact capture bounds are unavailable");
831
+ return { width: Math.ceil(value.width), height: Math.ceil(value.height) };
832
+ }
833
+
834
+ export async function captureBoundedScreenshot(
835
+ session: VerifierCdpSession,
836
+ bounded?: BoundedScreenshotCapture,
837
+ ): Promise<CapturedEvidenceImage | null> {
838
+ const shot = (await session.send(
839
+ "Page.captureScreenshot",
840
+ captureBoundedScreenshotParams("webp", bounded),
841
+ )) as { data?: string };
842
+ if (typeof shot.data !== "string" || shot.data.length === 0) return null;
843
+ if (shot.data.length > (TIER1_SCREENSHOT_CAP_BYTES * 4) / 3 + 4) return null;
844
+ const bytes = Buffer.from(shot.data, "base64");
845
+ if (bytes.byteLength > TIER1_SCREENSHOT_CAP_BYTES) return null;
846
+ return { bytes, format: "webp" };
847
+ }
848
+
849
+ export function captureBoundedScreenshotParams(
850
+ format: "png" | "webp",
851
+ bounded?: BoundedScreenshotCapture,
852
+ ): {
853
+ readonly format: "png" | "webp";
854
+ readonly quality?: number;
855
+ readonly captureBeyondViewport: true;
856
+ readonly clip?: {
857
+ readonly x: 0;
858
+ readonly y: 0;
859
+ readonly width: number;
860
+ readonly height: number;
861
+ readonly scale: number;
862
+ };
863
+ } {
864
+ return {
865
+ format,
866
+ ...(format === "webp" ? { quality: TIER1_SCREENSHOT_WEBP_QUALITY } : {}),
867
+ captureBeyondViewport: true,
868
+ ...(bounded === undefined || bounded.bounds.scale === 1
869
+ ? {}
870
+ : {
871
+ clip: {
872
+ x: 0,
873
+ y: 0,
874
+ width: bounded.source.width,
875
+ height: bounded.source.height,
876
+ scale: bounded.bounds.scale,
877
+ },
878
+ }),
879
+ };
880
+ }
881
+
882
+ async function captureBoundedPngScreenshot(
883
+ session: VerifierCdpSession,
884
+ bounded?: BoundedScreenshotCapture,
885
+ ): Promise<Buffer | null> {
886
+ const shot = (await session.send(
887
+ "Page.captureScreenshot",
888
+ captureBoundedScreenshotParams("png", bounded),
889
+ )) as { data?: string };
890
+ if (typeof shot.data !== "string" || shot.data.length === 0) return null;
891
+ return Buffer.from(shot.data, "base64");
892
+ }
893
+
894
+ export async function hasDeclaredAnimation(
895
+ input: Tier1Input,
896
+ session: VerifierCdpSession,
897
+ executionContextId: number,
898
+ ): Promise<boolean> {
899
+ if (input.execution === "interactive") return true;
900
+ const evaluated = (await session.send("Runtime.evaluate", {
901
+ contextId: executionContextId,
902
+ returnByValue: true,
903
+ expression:
904
+ "document.getAnimations().some(a => a.playState === 'running' || a.playState === 'pending')",
905
+ })) as { result?: { value?: unknown } };
906
+ return evaluated.result?.value === true;
907
+ }
908
+
909
+ async function captureAnimatedScreenshot(
910
+ session: VerifierCdpSession,
911
+ bounds?: BoundedScreenshotCapture,
912
+ ): Promise<CapturedEvidenceImage | null> {
913
+ if (bounds === undefined) return null;
914
+ const frames: Buffer[] = [];
915
+ for (let index = 0; index < TIER1_ANIMATION_FRAME_COUNT; index += 1) {
916
+ const frame = await captureBoundedPngScreenshot(session, bounds);
917
+ if (frame === null) return null;
918
+ frames.push(frame);
919
+ if (index < TIER1_ANIMATION_FRAME_COUNT - 1) {
920
+ await session.send("Runtime.evaluate", {
921
+ contextId: undefined,
922
+ expression: `new Promise(resolve => setTimeout(resolve, ${TIER1_ANIMATION_FRAME_INTERVAL_MS}))`,
923
+ awaitPromise: true,
924
+ returnByValue: true,
925
+ });
926
+ }
927
+ }
928
+ const bytes = await encodeAnimatedWebpWithinCap(frames, {
929
+ delayMs: TIER1_ANIMATION_FRAME_INTERVAL_MS,
930
+ qualities: TIER1_ANIMATION_WEBP_QUALITIES,
931
+ capBytes: TIER1_SCREENSHOT_CAP_BYTES,
932
+ });
933
+ return bytes === null ? null : { bytes, format: "webp" };
934
+ }
935
+
936
+ export async function captureEvidenceScreenshot(
937
+ session: VerifierCdpSession,
938
+ options: {
939
+ readonly animated: boolean;
940
+ readonly bounds: BoundedScreenshotCapture;
941
+ readonly captureStatic?: ScreenshotCapture;
942
+ },
943
+ ): Promise<{
944
+ readonly screenshot: CapturedEvidenceImage | null;
945
+ readonly screenshotError: ScreenshotError | null;
946
+ }> {
947
+ if (options.captureStatic !== undefined) {
948
+ return captureScreenshotWithRetry(session, {
949
+ bounds: options.bounds,
950
+ capture: options.captureStatic,
951
+ });
952
+ }
953
+ if (options.animated) {
954
+ return captureScreenshotWithRetry(session, {
955
+ bounds: options.bounds,
956
+ capture: captureAnimatedScreenshot,
957
+ });
958
+ }
959
+ return captureScreenshotWithRetry(session, { bounds: options.bounds });
960
+ }
961
+
962
+ interface ScreenshotRetryOptions {
963
+ readonly attempts?: number;
964
+ readonly timeoutMs?: number;
965
+ readonly capture?: ScreenshotCapture;
966
+ readonly bounds?: BoundedScreenshotCapture;
967
+ }
968
+
969
+ function screenshotFailureMessage(error: unknown): string {
970
+ const message = error instanceof Error ? error.message : String(error);
971
+ return `screenshot capture unavailable: ${message}`;
972
+ }
973
+
974
+ async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
975
+ let timer: ReturnType<typeof setTimeout> | undefined;
976
+ try {
977
+ return await Promise.race([
978
+ operation,
979
+ new Promise<never>((_resolve, reject) => {
980
+ timer = setTimeout(
981
+ () => reject(new Error(`screenshot capture timed out after ${timeoutMs}ms`)),
982
+ timeoutMs,
983
+ );
984
+ }),
985
+ ]);
986
+ } finally {
987
+ if (timer !== undefined) clearTimeout(timer);
988
+ }
989
+ }
990
+
991
+ export async function captureScreenshotWithRetry(
992
+ session: VerifierCdpSession,
993
+ options: ScreenshotRetryOptions = {},
994
+ ): Promise<{
995
+ readonly screenshot: CapturedEvidenceImage | null;
996
+ readonly screenshotError: ScreenshotError | null;
997
+ }> {
998
+ const attempts = options.attempts ?? TIER1_SCREENSHOT_CAPTURE_ATTEMPTS;
999
+ const timeoutMs = options.timeoutMs ?? TIER1_SCREENSHOT_CAPTURE_TIMEOUT_MS;
1000
+ const capture = options.capture ?? captureBoundedScreenshot;
1001
+ let failure: unknown = new Error("screenshot capture returned no data");
1002
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1003
+ const pendingCapture = capture(session, options.bounds);
1004
+ try {
1005
+ const screenshot = await withTimeout(pendingCapture, timeoutMs);
1006
+ if (screenshot !== null) return { screenshot, screenshotError: null };
1007
+ failure = new Error("screenshot capture returned no data");
1008
+ } catch (error) {
1009
+ failure = error;
1010
+ try {
1011
+ await withTimeout(
1012
+ pendingCapture.catch(() => undefined),
1013
+ timeoutMs,
1014
+ );
1015
+ } catch {
1016
+ break;
1017
+ }
1018
+ }
1019
+ }
1020
+ return {
1021
+ screenshot: null,
1022
+ screenshotError: { code: "screenshot_unavailable", message: screenshotFailureMessage(failure) },
1023
+ };
1024
+ }
1025
+
1026
+ async function captureEvidence(
1027
+ target: VerifierTarget,
1028
+ options: {
1029
+ readonly input: Tier1Input;
1030
+ readonly screenshotDir: string;
1031
+ readonly consolePath: string;
1032
+ readonly observationPath: string;
1033
+ readonly firstProtocolObservation: ProtocolObservation;
1034
+ readonly protocolObservation: ProtocolObservation;
1035
+ readonly pageShim: PageShim | null;
1036
+ readonly executionContextId: number;
1037
+ },
1038
+ captureScreenshot?: ScreenshotCapture,
1039
+ ): Promise<EvidenceCapture> {
1040
+ let screenshotPath: string | null = null;
1041
+ let screenshotFormat: EvidenceImageFormat | null = null;
1042
+ let screenshotError: ScreenshotError | null = null;
1043
+ try {
1044
+ const animated = await hasDeclaredAnimation(
1045
+ options.input,
1046
+ target.session,
1047
+ options.executionContextId,
1048
+ );
1049
+ // Animated evidence restores no-preference so captured frames reflect live motion;
1050
+ // static evidence keeps reduced motion for determinism.
1051
+ await target.session.send("Emulation.setEmulatedMedia", {
1052
+ features: [{ name: "prefers-reduced-motion", value: "reduce" }],
1053
+ });
1054
+ if (animated) {
1055
+ await target.session.send("Emulation.setEmulatedMedia", {
1056
+ features: [{ name: "prefers-reduced-motion", value: "no-preference" }],
1057
+ });
1058
+ }
1059
+ await target.session.send("Runtime.evaluate", {
1060
+ expression:
1061
+ "(async()=>{if(document.fonts&&document.fonts.ready){await document.fonts.ready;}" +
1062
+ "return 'ready';})()",
1063
+ awaitPromise: true,
1064
+ returnByValue: true,
1065
+ });
1066
+ const initialSize = await measureArtifactCaptureSize(
1067
+ target.session,
1068
+ options.executionContextId,
1069
+ );
1070
+ await configureTier1ViewportBounds(target.session, initialSize);
1071
+ await target.session.send("Runtime.evaluate", {
1072
+ contextId: options.executionContextId,
1073
+ expression: "new Promise(requestAnimationFrame)",
1074
+ awaitPromise: true,
1075
+ returnByValue: true,
1076
+ });
1077
+ const finalSize = await measureArtifactCaptureSize(target.session, options.executionContextId);
1078
+ if (finalSize.width > initialSize.width || finalSize.height > initialSize.height)
1079
+ throw new Error("artifact grew after viewport resize");
1080
+ const bounds = boundCaptureSize(finalSize);
1081
+ const captureOptions = { bounds: { bounds, source: finalSize } };
1082
+ const capture = await captureEvidenceScreenshot(target.session, {
1083
+ animated,
1084
+ bounds: captureOptions.bounds,
1085
+ ...(captureScreenshot === undefined ? {} : { captureStatic: captureScreenshot }),
1086
+ });
1087
+ screenshotError = capture.screenshotError;
1088
+ const screenshot = capture.screenshot;
1089
+ if (screenshot !== null) {
1090
+ screenshotPath = join(options.screenshotDir, `screenshot.${screenshot.format}`);
1091
+ writeFileSync(screenshotPath, screenshot.bytes);
1092
+ screenshotFormat = screenshot.format;
1093
+ }
1094
+ } catch (error) {
1095
+ screenshotError = {
1096
+ code: "screenshot_unavailable",
1097
+ message: screenshotFailureMessage(error),
1098
+ };
1099
+ }
1100
+ // Bounded console summary — never grow past CONSOLE_SUMMARY_CUP_BYTES.
1101
+ // The shim self-report + a fixed header covers the diagnostic surface;
1102
+ // a hostile artifact cannot inflate this past the cap.
1103
+ const summaryParts: string[] = [
1104
+ `tier1 evidence for revisionSha=${options.protocolObservation ? "ok" : "ok"}`,
1105
+ `protocol: ${JSON.stringify(options.protocolObservation)}`,
1106
+ ];
1107
+ if (options.pageShim !== null) {
1108
+ summaryParts.push(`shim: ${JSON.stringify(options.pageShim)}`);
1109
+ }
1110
+ const summary = summaryParts.join("\n").slice(0, CONSOLE_SUMMARY_CUP_BYTES);
1111
+ writeFileSync(options.consolePath, summary, "utf8");
1112
+ writeFileSync(
1113
+ options.observationPath,
1114
+ JSON.stringify(
1115
+ { first: options.firstProtocolObservation, second: options.protocolObservation },
1116
+ null,
1117
+ 2,
1118
+ ),
1119
+ "utf8",
1120
+ );
1121
+ return { screenshotPath, screenshotFormat, screenshotError, consolePath: options.consolePath };
1122
+ }