@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.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +23 -0
- package/README.md +158 -0
- package/dist/gallery/chunk-4g8rem85.css +656 -0
- package/dist/gallery/chunk-c6s9k9ty.js +5970 -0
- package/dist/gallery/frame/artifact.css +8424 -0
- package/dist/gallery/frame/chunks/abnfDiagram-N423BO3Z-jj74szbm.js +155 -0
- package/dist/gallery/frame/chunks/architecture-TIHT7OUA-cvdjn7dt.js +10 -0
- package/dist/gallery/frame/chunks/architectureDiagram-T3A2C74G-n4z77eg6.js +8675 -0
- package/dist/gallery/frame/chunks/blockDiagram-VBNYF7ZC-7xsqzrd1.js +3529 -0
- package/dist/gallery/frame/chunks/c4Diagram-5PPSVZJV-z2zjs7wx.js +2413 -0
- package/dist/gallery/frame/chunks/classDiagram-JCYQIIEL-ve8rngqp.js +47 -0
- package/dist/gallery/frame/chunks/classDiagram-v2-OCEON4UE-8cfaxb8z.js +47 -0
- package/dist/gallery/frame/chunks/cose-bilkent-JH36ORCC-sn92qexx.js +4822 -0
- package/dist/gallery/frame/chunks/cynefin-VYW2F7L2-tqjv913d.js +10 -0
- package/dist/gallery/frame/chunks/cynefinDiagram-MW4NZA55-c1c0dj12.js +500 -0
- package/dist/gallery/frame/chunks/dagre-VZM6K2ZE-7tyg4yt2.js +476 -0
- package/dist/gallery/frame/chunks/diagram-7IWD3JNH-y4eng9sc.js +526 -0
- package/dist/gallery/frame/chunks/diagram-B4RE2ZJO-rn3p7j5h.js +673 -0
- package/dist/gallery/frame/chunks/diagram-LBJQPF4R-w4a2cm9q.js +255 -0
- package/dist/gallery/frame/chunks/diagram-Q27KOJAE-aqc2v7wy.js +575 -0
- package/dist/gallery/frame/chunks/diagram-UB23O5K3-xncy8nyd.js +355 -0
- package/dist/gallery/frame/chunks/ebnfDiagram-BXEA7PRR-acwgxz16.js +175 -0
- package/dist/gallery/frame/chunks/erDiagram-JOGREHBK-mzp5nswt.js +1332 -0
- package/dist/gallery/frame/chunks/eventmodeling-45OFAUF4-sg07qr0c.js +10 -0
- package/dist/gallery/frame/chunks/flowDiagram-UKHOOZJN-2e5ygs44.js +29 -0
- package/dist/gallery/frame/chunks/ganttDiagram-PKOTCBZU-p2hhh7hr.js +2620 -0
- package/dist/gallery/frame/chunks/gitGraph-TEB2WS4Q-j6kw0naa.js +10 -0
- package/dist/gallery/frame/chunks/gitGraphDiagram-DS77QQ5N-4z9z8wjm.js +1347 -0
- package/dist/gallery/frame/chunks/info-DKCQHKI2-mpt80jd3.js +10 -0
- package/dist/gallery/frame/chunks/infoDiagram-6WML65LV-5rqm3mgg.js +65 -0
- package/dist/gallery/frame/chunks/ishikawaDiagram-WSZJBQD7-b51z96jy.js +975 -0
- package/dist/gallery/frame/chunks/journeyDiagram-NVQOT4AX-9jbfp0pk.js +1272 -0
- package/dist/gallery/frame/chunks/kanban-definition-27J2QSJJ-4x0vx4se.js +1116 -0
- package/dist/gallery/frame/chunks/katex-xfgk95tv.js +14034 -0
- package/dist/gallery/frame/chunks/markdown-1s5vwkf2.js +9 -0
- package/dist/gallery/frame/chunks/markdown-2h6bwsms.js +3239 -0
- package/dist/gallery/frame/chunks/markdown-2x113yh8.js +107 -0
- package/dist/gallery/frame/chunks/markdown-30b7rwmp.js +1943 -0
- package/dist/gallery/frame/chunks/markdown-356bfj4g.js +2031 -0
- package/dist/gallery/frame/chunks/markdown-44n9ea69.js +2406 -0
- package/dist/gallery/frame/chunks/markdown-56enrf2r.js +39 -0
- package/dist/gallery/frame/chunks/markdown-5avymm8t.js +725 -0
- package/dist/gallery/frame/chunks/markdown-6r4svars.js +33 -0
- package/dist/gallery/frame/chunks/markdown-6zz8efx2.js +24 -0
- package/dist/gallery/frame/chunks/markdown-7jp6prnb.js +19 -0
- package/dist/gallery/frame/chunks/markdown-809k7q41.js +55 -0
- package/dist/gallery/frame/chunks/markdown-82xmzhzd.js +1076 -0
- package/dist/gallery/frame/chunks/markdown-92n37gd4.js +7627 -0
- package/dist/gallery/frame/chunks/markdown-ag2sssf8.js +36 -0
- package/dist/gallery/frame/chunks/markdown-b4s7ntq2.js +19 -0
- package/dist/gallery/frame/chunks/markdown-c9k0fvp1.js +100 -0
- package/dist/gallery/frame/chunks/markdown-ceefymn8.js +125 -0
- package/dist/gallery/frame/chunks/markdown-d1wfbs70.js +243 -0
- package/dist/gallery/frame/chunks/markdown-dy5sgjhc.js +11022 -0
- package/dist/gallery/frame/chunks/markdown-dz3z5w30.js +30868 -0
- package/dist/gallery/frame/chunks/markdown-eg6ngz7y.js +30340 -0
- package/dist/gallery/frame/chunks/markdown-ff5wvb43.js +1149 -0
- package/dist/gallery/frame/chunks/markdown-ffg06nfz.js +86 -0
- package/dist/gallery/frame/chunks/markdown-fvfncadf.js +472 -0
- package/dist/gallery/frame/chunks/markdown-gvyszqpv.js +19 -0
- package/dist/gallery/frame/chunks/markdown-gx1p0ew0.js +88 -0
- package/dist/gallery/frame/chunks/markdown-gxye5frz.js +65 -0
- package/dist/gallery/frame/chunks/markdown-h7pzv34e.js +105 -0
- package/dist/gallery/frame/chunks/markdown-hab789t9.js +47 -0
- package/dist/gallery/frame/chunks/markdown-hjp6sj8t.js +125 -0
- package/dist/gallery/frame/chunks/markdown-jka64qgp.js +1993 -0
- package/dist/gallery/frame/chunks/markdown-k8xkn8mw.js +56 -0
- package/dist/gallery/frame/chunks/markdown-m7frq8x8.js +112 -0
- package/dist/gallery/frame/chunks/markdown-p740v4qm.js +36 -0
- package/dist/gallery/frame/chunks/markdown-pxfeh231.js +57 -0
- package/dist/gallery/frame/chunks/markdown-pzgd8z3s.js +87 -0
- package/dist/gallery/frame/chunks/markdown-qx3cngz2.js +1673 -0
- package/dist/gallery/frame/chunks/markdown-rawhfvvc.js +85 -0
- package/dist/gallery/frame/chunks/markdown-rd8w0ng9.js +49 -0
- package/dist/gallery/frame/chunks/markdown-rm2yxfjj.js +2773 -0
- package/dist/gallery/frame/chunks/markdown-s1jatrnk.js +993 -0
- package/dist/gallery/frame/chunks/markdown-sgfg5vvq.js +59 -0
- package/dist/gallery/frame/chunks/markdown-t4ethv9d.js +409 -0
- package/dist/gallery/frame/chunks/markdown-tnmabqvz.js +36 -0
- package/dist/gallery/frame/chunks/markdown-tvdpppw8.js +5292 -0
- package/dist/gallery/frame/chunks/markdown-v0x27twr.js +22 -0
- package/dist/gallery/frame/chunks/markdown-v14td68x.js +36 -0
- package/dist/gallery/frame/chunks/markdown-w6sgd54z.js +36 -0
- package/dist/gallery/frame/chunks/markdown-xw8dsk8b.js +2125 -0
- package/dist/gallery/frame/chunks/markdown-y18mve73.js +85 -0
- package/dist/gallery/frame/chunks/markdown-yengsms5.js +427 -0
- package/dist/gallery/frame/chunks/markdown-z1wc0v0d.js +1826 -0
- package/dist/gallery/frame/chunks/markdown-zazx9h4n.js +329 -0
- package/dist/gallery/frame/chunks/mermaid-de7p4v0x.js +30 -0
- package/dist/gallery/frame/chunks/mindmap-definition-FAOFIHXS-ra44cbem.js +1216 -0
- package/dist/gallery/frame/chunks/packet-7NZHBO7P-xppm5913.js +10 -0
- package/dist/gallery/frame/chunks/pegDiagram-VL7TDLO6-a2a4wg03.js +163 -0
- package/dist/gallery/frame/chunks/pie-RZYD4A2V-hnbxk152.js +10 -0
- package/dist/gallery/frame/chunks/pieDiagram-7S7Q4E2Y-f1vxjtmx.js +311 -0
- package/dist/gallery/frame/chunks/quadrantDiagram-CIZ2JOQS-ywafa6kh.js +1390 -0
- package/dist/gallery/frame/chunks/radar-I7S5WNFK-ewqd27se.js +10 -0
- package/dist/gallery/frame/chunks/railroad-3IZDKUUU-5hy4bdx3.js +10 -0
- package/dist/gallery/frame/chunks/railroad-abnf-AHOZXSZD-bh5sxz0d.js +10 -0
- package/dist/gallery/frame/chunks/railroad-ebnf-EBAXGLYW-6b9c3zeh.js +10 -0
- package/dist/gallery/frame/chunks/railroad-peg-LSFZ7HO6-avc8v39s.js +10 -0
- package/dist/gallery/frame/chunks/railroadDiagram-AXF67PYL-qc61wax4.js +131 -0
- package/dist/gallery/frame/chunks/requirementDiagram-LRYGKXZP-9bs4786c.js +1295 -0
- package/dist/gallery/frame/chunks/sankeyDiagram-W5VNT64P-awt1em8b.js +1339 -0
- package/dist/gallery/frame/chunks/sequenceDiagram-SI44F4Z6-qaf9mz3t.js +4324 -0
- package/dist/gallery/frame/chunks/sizeCapture-X5ZJPWSS-9fq685xd.js +64 -0
- package/dist/gallery/frame/chunks/stateDiagram-OKZ733FA-vx7a7hh0.js +456 -0
- package/dist/gallery/frame/chunks/stateDiagram-v2-UEYNNEHI-x4m6ws5t.js +46 -0
- package/dist/gallery/frame/chunks/swimlanes-SLNWSIFB-e4mxm1gh.js +8287 -0
- package/dist/gallery/frame/chunks/swimlanesDiagram-ULZ7WXOC-0h93e2qq.js +42 -0
- package/dist/gallery/frame/chunks/timeline-definition-Z64GVDOM-wp0bhbzg.js +1549 -0
- package/dist/gallery/frame/chunks/treeView-QDETBFTQ-hcx7n0vt.js +10 -0
- package/dist/gallery/frame/chunks/treemap-6X3UGDF4-av02skdw.js +10 -0
- package/dist/gallery/frame/chunks/vennDiagram-T6HMQDX7-pmvk1p7n.js +2566 -0
- package/dist/gallery/frame/chunks/wardley-OPB4EBWU-njsp6e40.js +10 -0
- package/dist/gallery/frame/chunks/wardleyDiagram-T6FBY63Y-eggmnv4z.js +979 -0
- package/dist/gallery/frame/chunks/xychartDiagram-ELKLHX3M-q40tnpe5.js +1954 -0
- package/dist/gallery/frame/frame.css +183 -0
- package/dist/gallery/frame/runtime/chart.js +42744 -0
- package/dist/gallery/frame/runtime/html.js +14 -0
- package/dist/gallery/frame/runtime/markdown.js +1403 -0
- package/dist/gallery/frame/runtime/mermaid.js +32 -0
- package/dist/gallery/frame/runtime/svg.js +14 -0
- package/dist/gallery/frame/runtime/tsx.js +52 -0
- package/dist/gallery/index.html +104 -0
- package/docs/reference/cli.md +163 -0
- package/docs/reference/export.md +106 -0
- package/docs/reference/html.md +215 -0
- package/docs/reference/http.md +85 -0
- package/docs/reference/mcp.md +78 -0
- package/docs/reference/security.md +119 -0
- package/docs/reference/storage.md +93 -0
- package/docs/reference/tsx.md +101 -0
- package/docs/reference/validation.md +212 -0
- package/package.json +89 -0
- package/scripts/build-gallery.ts +127 -0
- package/scripts/launch-netns.sh +67 -0
- package/skills/facet/SKILL.md +100 -0
- package/src/cli/client.ts +311 -0
- package/src/cli/commands/create.ts +49 -0
- package/src/cli/commands/doctor.ts +249 -0
- package/src/cli/commands/export.ts +179 -0
- package/src/cli/commands/instantiate.ts +35 -0
- package/src/cli/commands/list.ts +49 -0
- package/src/cli/commands/open.ts +75 -0
- package/src/cli/commands/pin.ts +39 -0
- package/src/cli/commands/promote.ts +49 -0
- package/src/cli/commands/publish.ts +122 -0
- package/src/cli/commands/read-back.ts +56 -0
- package/src/cli/commands/status.ts +114 -0
- package/src/cli/commands/watch.ts +146 -0
- package/src/cli/main.ts +522 -0
- package/src/cli/output.ts +106 -0
- package/src/cli/parser.ts +376 -0
- package/src/cli/presenter.ts +212 -0
- package/src/cli/service-metadata.ts +134 -0
- package/src/cli/spawn-service.ts +326 -0
- package/src/css.d.ts +4 -0
- package/src/gallery-web/app.ts +1581 -0
- package/src/gallery-web/export-menu.ts +121 -0
- package/src/gallery-web/export.ts +112 -0
- package/src/gallery-web/favicon.ts +54 -0
- package/src/gallery-web/frame/entries/chart.ts +6 -0
- package/src/gallery-web/frame/entries/html.ts +6 -0
- package/src/gallery-web/frame/entries/markdown.ts +6 -0
- package/src/gallery-web/frame/entries/mermaid.ts +6 -0
- package/src/gallery-web/frame/entries/svg.ts +6 -0
- package/src/gallery-web/frame/entries/tsx.ts +6 -0
- package/src/gallery-web/frame/frame-payload.ts +91 -0
- package/src/gallery-web/frame/renderer-validation.ts +13 -0
- package/src/gallery-web/frame/renderers/chart.ts +182 -0
- package/src/gallery-web/frame/renderers/dompurify-shim.ts +112 -0
- package/src/gallery-web/frame/renderers/html.ts +48 -0
- package/src/gallery-web/frame/renderers/markdown.ts +81 -0
- package/src/gallery-web/frame/renderers/mermaid.ts +120 -0
- package/src/gallery-web/frame/renderers/registry.ts +260 -0
- package/src/gallery-web/frame/renderers/svg.ts +393 -0
- package/src/gallery-web/frame/renderers/tsx.ts +61 -0
- package/src/gallery-web/frame/runtime.ts +501 -0
- package/src/gallery-web/frame/styles/artifact.css +8424 -0
- package/src/gallery-web/frame/styles/frame.css +183 -0
- package/src/gallery-web/frame/styles/html-source.css +23 -0
- package/src/gallery-web/frame/view-box.ts +33 -0
- package/src/gallery-web/frame-html.ts +72 -0
- package/src/gallery-web/index.html +104 -0
- package/src/gallery-web/session.ts +107 -0
- package/src/gallery-web/sse-client.ts +212 -0
- package/src/gallery-web/styles/app.css +420 -0
- package/src/gallery-web/styles/tokens.css +112 -0
- package/src/gallery-web/styles/verdict.css +186 -0
- package/src/gallery-web/swap.ts +64 -0
- package/src/gallery-web/theme.ts +40 -0
- package/src/gallery-web/view-state.ts +93 -0
- package/src/harness-adapters/claude-code/README.md +10 -0
- package/src/harness-adapters/claude-code/facet.sh +4 -0
- package/src/harness-adapters/codex/README.md +10 -0
- package/src/harness-adapters/codex/facet.sh +4 -0
- package/src/harness-adapters/mcp/README.md +13 -0
- package/src/harness-adapters/mcp/cli-bridge.ts +216 -0
- package/src/harness-adapters/mcp/main.ts +15 -0
- package/src/harness-adapters/mcp/server.ts +164 -0
- package/src/harness-adapters/mcp/tool-schemas.ts +53 -0
- package/src/harness-adapters/opencode/README.md +10 -0
- package/src/harness-adapters/opencode/facet.sh +4 -0
- package/src/runtime/compiled-entrypoints.ts +69 -0
- package/src/service/dispatcher.ts +717 -0
- package/src/service/export.ts +218 -0
- package/src/service/http-utils.ts +55 -0
- package/src/service/lexical/expectations.ts +210 -0
- package/src/service/lifecycle/idle-controller.ts +125 -0
- package/src/service/lifecycle/orphan-cleanup.ts +128 -0
- package/src/service/lifecycle/process-lock.ts +235 -0
- package/src/service/main.ts +136 -0
- package/src/service/process.ts +240 -0
- package/src/service/router-guards.ts +239 -0
- package/src/service/router.ts +627 -0
- package/src/service/security/auth.ts +168 -0
- package/src/service/security/host-origin.ts +138 -0
- package/src/service/security/http-guards.ts +13 -0
- package/src/service/security/leases.ts +182 -0
- package/src/service/security/token-store.ts +138 -0
- package/src/service/server.ts +356 -0
- package/src/service/store/database.ts +61 -0
- package/src/service/store/evidence-retention.ts +234 -0
- package/src/service/store/migrations.ts +167 -0
- package/src/service/store/repository-lifecycle.ts +121 -0
- package/src/service/store/repository.ts +732 -0
- package/src/service/store/schema.ts +218 -0
- package/src/service/stored-verdict.ts +97 -0
- package/src/service/stream.ts +301 -0
- package/src/service/verdict-enrichment.ts +45 -0
- package/src/shared/build/frame-bundle-plugins.ts +61 -0
- package/src/shared/build-mode.ts +28 -0
- package/src/shared/config/evidence-read.ts +98 -0
- package/src/shared/config/limits.ts +56 -0
- package/src/shared/config/paths.ts +96 -0
- package/src/shared/contracts/artifact-types.ts +2 -0
- package/src/shared/contracts/artifact.ts +98 -0
- package/src/shared/contracts/commands/_shared.ts +59 -0
- package/src/shared/contracts/commands/guards.ts +86 -0
- package/src/shared/contracts/commands/index.ts +204 -0
- package/src/shared/contracts/commands/names.ts +35 -0
- package/src/shared/contracts/commands/requests.ts +149 -0
- package/src/shared/contracts/commands/results.ts +200 -0
- package/src/shared/contracts/envelope.ts +140 -0
- package/src/shared/contracts/events.ts +41 -0
- package/src/shared/contracts/observed-counts.ts +19 -0
- package/src/shared/contracts/renderers.ts +6 -0
- package/src/shared/contracts/validation.ts +296 -0
- package/src/shared/errors/facet-error.ts +127 -0
- package/src/shared/errors/store-error.ts +68 -0
- package/src/shared/evidence-image.ts +37 -0
- package/src/shared/export.ts +31 -0
- package/src/shared/html/policy.ts +108 -0
- package/src/shared/html/style-vocabulary.ts +352 -0
- package/src/shared/logging/logger.ts +108 -0
- package/src/shared/logging/redact.ts +49 -0
- package/src/shared/security/frozen-csp.ts +13 -0
- package/src/shared/storage-version.ts +2 -0
- package/src/shared/tsx/execution.ts +29 -0
- package/src/shared/tsx/import-policy.ts +94 -0
- package/src/shared/util/dir-permissions.ts +94 -0
- package/src/shared/util/mermaid-nodes.ts +230 -0
- package/src/shared/util/process.ts +67 -0
- package/src/shared/util/time.ts +21 -0
- package/src/shared/version.ts +3 -0
- package/src/validation/sandbox/limits.ts +41 -0
- package/src/validation/sandbox/netns.ts +177 -0
- package/src/validation/tier0/chart.ts +177 -0
- package/src/validation/tier0/dom-shim.ts +54 -0
- package/src/validation/tier0/html.ts +408 -0
- package/src/validation/tier0/markdown.ts +285 -0
- package/src/validation/tier0/mermaid.ts +98 -0
- package/src/validation/tier0/runner.ts +504 -0
- package/src/validation/tier0/svg.ts +321 -0
- package/src/validation/tier0/tsx/allowlist-resolver.ts +77 -0
- package/src/validation/tier0/tsx/ast-policy-capabilities.ts +238 -0
- package/src/validation/tier0/tsx/ast-policy-shared.ts +71 -0
- package/src/validation/tier0/tsx/ast-policy.ts +181 -0
- package/src/validation/tier0/tsx/compiler.ts +156 -0
- package/src/validation/tier0/worker-dispatch.ts +211 -0
- package/src/validation/tier0/worker-entry.ts +96 -0
- package/src/validation/tier0/worker-input.ts +75 -0
- package/src/validation/tier1/browser-process.ts +147 -0
- package/src/validation/tier1/cdp-pipe.ts +281 -0
- package/src/validation/tier1/entries/chart.ts +5 -0
- package/src/validation/tier1/entries/html.ts +6 -0
- package/src/validation/tier1/entries/markdown.ts +5 -0
- package/src/validation/tier1/entries/mermaid.ts +5 -0
- package/src/validation/tier1/entries/svg.ts +5 -0
- package/src/validation/tier1/entries/tsx.ts +5 -0
- package/src/validation/tier1/frame-target.ts +162 -0
- package/src/validation/tier1/harness-entry.ts +261 -0
- package/src/validation/tier1/harness.ts +215 -0
- package/src/validation/tier1/isolated-probe.ts +94 -0
- package/src/validation/tier1/launcher.ts +177 -0
- package/src/validation/tier1/limits.ts +80 -0
- package/src/validation/tier1/nonce.ts +17 -0
- package/src/validation/tier1/protocol-probe.ts +519 -0
- package/src/validation/tier1/runner.ts +1122 -0
- package/src/validation/tier1/verdict.ts +246 -0
- package/src/validation/tier1/webp.ts +69 -0
- package/templates/README.md +88 -0
- package/templates/bar-compare.vl.json +63 -0
- package/templates/capacity-report.tsx +102 -0
- package/templates/decision-record.md +24 -0
- package/templates/deployment-state.mmd +40 -0
- package/templates/exemplar.md +91 -0
- package/templates/facet-story.tsx +799 -0
- package/templates/fleet-dashboard.html +141 -0
- package/templates/html-release-ledger.html +91 -0
- package/templates/html-status-report.html +114 -0
- package/templates/incident-console.tsx +147 -0
- package/templates/legion-boundaries.mmd +23 -0
- package/templates/legion-flow.mmd +15 -0
- package/templates/legion-sequence.mmd +14 -0
- package/templates/legion-state.mmd +7 -0
- package/templates/metric-card.svg +29 -0
- package/templates/observability-map.svg +101 -0
- package/templates/pipeline-audit.md +108 -0
- package/templates/release-metrics.vl.json +98 -0
- package/templates/service-topology.mmd +47 -0
- package/templates/status-report.md +32 -0
- package/templates/system-map.svg +53 -0
- package/templates/timeseries.vl.json +71 -0
- package/templates/tsx-interactive-counter.tsx +19 -0
- 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
|
+
}
|