@gate-forge/pack-playwright 0.7.0 → 0.8.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 (200) hide show
  1. package/README.md +31 -0
  2. package/dist/constants.d.ts +13 -140
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +13 -148
  5. package/dist/constants.js.map +1 -1
  6. package/dist/cypress/plugin.d.ts +53 -0
  7. package/dist/cypress/plugin.d.ts.map +1 -0
  8. package/dist/cypress/plugin.js +369 -0
  9. package/dist/cypress/plugin.js.map +1 -0
  10. package/dist/cypress/spec-scan.d.ts +39 -0
  11. package/dist/cypress/spec-scan.d.ts.map +1 -0
  12. package/dist/cypress/spec-scan.js +301 -0
  13. package/dist/cypress/spec-scan.js.map +1 -0
  14. package/dist/diagnosis.d.ts +39 -0
  15. package/dist/diagnosis.d.ts.map +1 -0
  16. package/dist/diagnosis.js +63 -0
  17. package/dist/diagnosis.js.map +1 -0
  18. package/dist/discovery/adapter-projection.d.ts +13 -0
  19. package/dist/discovery/adapter-projection.d.ts.map +1 -0
  20. package/dist/discovery/adapter-projection.js +84 -0
  21. package/dist/discovery/adapter-projection.js.map +1 -0
  22. package/dist/discovery/config-locations.d.ts +5 -0
  23. package/dist/discovery/config-locations.d.ts.map +1 -0
  24. package/dist/discovery/config-locations.js +19 -0
  25. package/dist/discovery/config-locations.js.map +1 -0
  26. package/dist/discovery/cypress-runner-adapter.d.ts +112 -0
  27. package/dist/discovery/cypress-runner-adapter.d.ts.map +1 -0
  28. package/dist/discovery/cypress-runner-adapter.js +501 -0
  29. package/dist/discovery/cypress-runner-adapter.js.map +1 -0
  30. package/dist/discovery/discover.d.ts +53 -1
  31. package/dist/discovery/discover.d.ts.map +1 -1
  32. package/dist/discovery/discover.js +100 -3
  33. package/dist/discovery/discover.js.map +1 -1
  34. package/dist/discovery/git-ignore.d.ts +13 -0
  35. package/dist/discovery/git-ignore.d.ts.map +1 -0
  36. package/dist/discovery/git-ignore.js +62 -0
  37. package/dist/discovery/git-ignore.js.map +1 -0
  38. package/dist/discovery/index.d.ts +9 -0
  39. package/dist/discovery/index.d.ts.map +1 -1
  40. package/dist/discovery/index.js +9 -0
  41. package/dist/discovery/index.js.map +1 -1
  42. package/dist/discovery/playwright-runner-adapter.d.ts +108 -0
  43. package/dist/discovery/playwright-runner-adapter.d.ts.map +1 -0
  44. package/dist/discovery/playwright-runner-adapter.js +185 -0
  45. package/dist/discovery/playwright-runner-adapter.js.map +1 -0
  46. package/dist/discovery/prepare-barrier.d.ts +522 -0
  47. package/dist/discovery/prepare-barrier.d.ts.map +1 -0
  48. package/dist/discovery/prepare-barrier.js +739 -0
  49. package/dist/discovery/prepare-barrier.js.map +1 -0
  50. package/dist/discovery/pytest-adapter.d.ts.map +1 -1
  51. package/dist/discovery/pytest-adapter.js +6 -2
  52. package/dist/discovery/pytest-adapter.js.map +1 -1
  53. package/dist/discovery/pytest-runner-adapter.d.ts +124 -0
  54. package/dist/discovery/pytest-runner-adapter.d.ts.map +1 -0
  55. package/dist/discovery/pytest-runner-adapter.js +454 -0
  56. package/dist/discovery/pytest-runner-adapter.js.map +1 -0
  57. package/dist/discovery/reconcile.d.ts +80 -11
  58. package/dist/discovery/reconcile.d.ts.map +1 -1
  59. package/dist/discovery/reconcile.js +395 -78
  60. package/dist/discovery/reconcile.js.map +1 -1
  61. package/dist/discovery/runner-env.d.ts +88 -2
  62. package/dist/discovery/runner-env.d.ts.map +1 -1
  63. package/dist/discovery/runner-env.js +142 -4
  64. package/dist/discovery/runner-env.js.map +1 -1
  65. package/dist/discovery/static-discovery.d.ts +38 -3
  66. package/dist/discovery/static-discovery.d.ts.map +1 -1
  67. package/dist/discovery/static-discovery.js +454 -15
  68. package/dist/discovery/static-discovery.js.map +1 -1
  69. package/dist/discovery/supervised-run.d.ts +199 -10
  70. package/dist/discovery/supervised-run.d.ts.map +1 -1
  71. package/dist/discovery/supervised-run.js +350 -39
  72. package/dist/discovery/supervised-run.js.map +1 -1
  73. package/dist/discovery/trusted-config.d.ts +180 -6
  74. package/dist/discovery/trusted-config.d.ts.map +1 -1
  75. package/dist/discovery/trusted-config.js +279 -10
  76. package/dist/discovery/trusted-config.js.map +1 -1
  77. package/dist/discovery/vitest-runner-adapter.d.ts +172 -0
  78. package/dist/discovery/vitest-runner-adapter.d.ts.map +1 -0
  79. package/dist/discovery/vitest-runner-adapter.js +607 -0
  80. package/dist/discovery/vitest-runner-adapter.js.map +1 -0
  81. package/dist/fixture/consumer-runner.d.ts +4 -0
  82. package/dist/fixture/consumer-runner.d.ts.map +1 -0
  83. package/dist/fixture/consumer-runner.js +38 -0
  84. package/dist/fixture/consumer-runner.js.map +1 -0
  85. package/dist/fixture/evidence.d.ts +27 -0
  86. package/dist/fixture/evidence.d.ts.map +1 -1
  87. package/dist/fixture/evidence.js +12 -0
  88. package/dist/fixture/evidence.js.map +1 -1
  89. package/dist/fixture/fixture.d.ts +2 -1
  90. package/dist/fixture/fixture.d.ts.map +1 -1
  91. package/dist/fixture/fixture.js +1 -17
  92. package/dist/fixture/fixture.js.map +1 -1
  93. package/dist/fixture/witness-client.d.ts +6 -174
  94. package/dist/fixture/witness-client.d.ts.map +1 -1
  95. package/dist/fixture/witness-client.js +6 -267
  96. package/dist/fixture/witness-client.js.map +1 -1
  97. package/dist/index.d.ts +9 -6
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +5 -4
  100. package/dist/index.js.map +1 -1
  101. package/dist/json.d.ts +6 -12
  102. package/dist/json.d.ts.map +1 -1
  103. package/dist/json.js +6 -17
  104. package/dist/json.js.map +1 -1
  105. package/dist/reporter/project-graph-reporter.d.ts +92 -0
  106. package/dist/reporter/project-graph-reporter.d.ts.map +1 -0
  107. package/dist/reporter/project-graph-reporter.js +92 -0
  108. package/dist/reporter/project-graph-reporter.js.map +1 -0
  109. package/dist/reporter/reporter.cjs.map +1 -1
  110. package/dist/reporter/reporter.d.ts +93 -4
  111. package/dist/reporter/reporter.d.ts.map +1 -1
  112. package/dist/reporter/reporter.js +174 -18
  113. package/dist/reporter/reporter.js.map +1 -1
  114. package/dist/runner-claims.d.ts +13 -0
  115. package/dist/runner-claims.d.ts.map +1 -0
  116. package/dist/runner-claims.js +46 -0
  117. package/dist/runner-claims.js.map +1 -0
  118. package/dist/runner-resolution.d.ts +3 -0
  119. package/dist/runner-resolution.d.ts.map +1 -0
  120. package/dist/runner-resolution.js +14 -0
  121. package/dist/runner-resolution.js.map +1 -0
  122. package/dist/supervisor/client.d.ts +23 -1
  123. package/dist/supervisor/client.d.ts.map +1 -1
  124. package/dist/supervisor/client.js +49 -0
  125. package/dist/supervisor/client.js.map +1 -1
  126. package/dist/supervisor/drain.d.ts +65 -0
  127. package/dist/supervisor/drain.d.ts.map +1 -1
  128. package/dist/supervisor/drain.js +353 -9
  129. package/dist/supervisor/drain.js.map +1 -1
  130. package/dist/supervisor/index.d.ts +7 -3
  131. package/dist/supervisor/index.d.ts.map +1 -1
  132. package/dist/supervisor/index.js +7 -3
  133. package/dist/supervisor/index.js.map +1 -1
  134. package/dist/supervisor/spool.d.ts +65 -2
  135. package/dist/supervisor/spool.d.ts.map +1 -1
  136. package/dist/supervisor/spool.js +47 -0
  137. package/dist/supervisor/spool.js.map +1 -1
  138. package/dist/surface.d.ts +6 -207
  139. package/dist/surface.d.ts.map +1 -1
  140. package/dist/surface.js +6 -264
  141. package/dist/surface.js.map +1 -1
  142. package/dist/vitest/index.d.ts +23 -0
  143. package/dist/vitest/index.d.ts.map +1 -0
  144. package/dist/vitest/index.js +356 -0
  145. package/dist/vitest/index.js.map +1 -0
  146. package/dist/vitest/reporter.d.ts +83 -0
  147. package/dist/vitest/reporter.d.ts.map +1 -0
  148. package/dist/vitest/reporter.js +211 -0
  149. package/dist/vitest/reporter.js.map +1 -0
  150. package/dist/vitest/worker-slot.d.ts +36 -0
  151. package/dist/vitest/worker-slot.d.ts.map +1 -0
  152. package/dist/vitest/worker-slot.js +51 -0
  153. package/dist/vitest/worker-slot.js.map +1 -0
  154. package/dist/witness/adapter-registry.d.ts +6 -50
  155. package/dist/witness/adapter-registry.d.ts.map +1 -1
  156. package/dist/witness/adapter-registry.js +6 -257
  157. package/dist/witness/adapter-registry.js.map +1 -1
  158. package/dist/witness/behavior-request.d.ts +6 -76
  159. package/dist/witness/behavior-request.d.ts.map +1 -1
  160. package/dist/witness/behavior-request.js +6 -273
  161. package/dist/witness/behavior-request.js.map +1 -1
  162. package/dist/witness/behavior.d.ts +6 -48
  163. package/dist/witness/behavior.d.ts.map +1 -1
  164. package/dist/witness/behavior.js +6 -114
  165. package/dist/witness/behavior.js.map +1 -1
  166. package/dist/witness/bin.d.ts +6 -17
  167. package/dist/witness/bin.d.ts.map +1 -1
  168. package/dist/witness/bin.js +6 -168
  169. package/dist/witness/bin.js.map +1 -1
  170. package/dist/witness/browser.d.ts +14 -206
  171. package/dist/witness/browser.d.ts.map +1 -1
  172. package/dist/witness/browser.js +15 -654
  173. package/dist/witness/browser.js.map +1 -1
  174. package/dist/witness/classifications.d.ts +6 -38
  175. package/dist/witness/classifications.d.ts.map +1 -1
  176. package/dist/witness/classifications.js +6 -83
  177. package/dist/witness/classifications.js.map +1 -1
  178. package/dist/witness/env-attestation.d.ts +6 -94
  179. package/dist/witness/env-attestation.d.ts.map +1 -1
  180. package/dist/witness/env-attestation.js +6 -198
  181. package/dist/witness/env-attestation.js.map +1 -1
  182. package/dist/witness/fixture-provider.d.ts +6 -82
  183. package/dist/witness/fixture-provider.d.ts.map +1 -1
  184. package/dist/witness/fixture-provider.js +6 -105
  185. package/dist/witness/fixture-provider.js.map +1 -1
  186. package/dist/witness/loopback-pins.d.ts +6 -75
  187. package/dist/witness/loopback-pins.d.ts.map +1 -1
  188. package/dist/witness/loopback-pins.js +6 -240
  189. package/dist/witness/loopback-pins.js.map +1 -1
  190. package/dist/witness/server.d.ts +8 -26
  191. package/dist/witness/server.d.ts.map +1 -1
  192. package/dist/witness/server.js +24 -3761
  193. package/dist/witness/server.js.map +1 -1
  194. package/dist/witness/types.d.ts +6 -882
  195. package/dist/witness/types.d.ts.map +1 -1
  196. package/dist/witness/types.js +9 -1
  197. package/dist/witness/types.js.map +1 -1
  198. package/examples/overlay-proof.spec.js +75 -0
  199. package/package.json +8 -2
  200. package/python/gateforge_pytest_plugin.py +462 -0
@@ -1,3774 +1,37 @@
1
1
  /**
2
- * The loopback witness service (pin #7, owner G6).
2
+ * Compatibility re-export: `witness/server` now lives in the
3
+ * runner-neutral `@gate-forge/witness` package (plan 2026-09-25
4
+ * phase 1).
3
5
  *
4
- * A node:http server on an OS-assigned port, reachable ONLY on loopback.
5
- * Every request must carry `x-gateforge-run: <token>` (per-run token);
6
- * without it the witness answers 401. The run token is the SUITE's
7
- * credential; a second, stronger capability — the verifier key
8
- * (`x-gateforge-verifier`), which the tested suite NEVER receives —
9
- * guards the supervisor surface. Endpoint authority (enforcement-review
10
- * fix 3: the runner child holds NO supervisor rights):
6
+ * The move is physical, not behavioural: this module forwards every
7
+ * export unchanged so `@gate-forge/pack-playwright`'s published import
8
+ * paths — which consumers pin — keep resolving to the same values.
11
9
  *
12
- * | Endpoint | Required authority |
13
- * |-----------------------------------|-----------------------------------------|
14
- * | `GET /health` | run token |
15
- * | `GET /records` | run token |
16
- * | `GET /classifications` | run token |
17
- * | `POST /records` | run token + OPEN session credential |
18
- * | `POST /witness/pre-observation` | run token + OPEN session credential |
19
- * | `POST /witness/persistence` | run token + OPEN session credential |
20
- * | `POST /witness/http-observation` | run token + OPEN session credential |
21
- * | `POST /sessions/resolve` | run token (worker proves its identity; |
22
- * | | answers only OPEN supervisor sessions) |
23
- * | `POST /sessions/intervals/*` | run token + OPEN session credential |
24
- * | `POST /browser/surface` | run token + OPEN session credential |
25
- * | `POST /browser/action` | run token + OPEN session credential |
26
- * | `POST /browser/visible` | run token + OPEN session credential |
27
- * | `POST /run-context` | run token + verifier key (supervisor) |
28
- * | `GET /ledger-attestation` | run token + verifier key (supervisor) |
29
- * | `POST /runs/expected-set` | run token + verifier key (supervisor) |
30
- * | `POST /runs/server-e2e-declarations` | run token + verifier key (supervisor)|
31
- * | `POST /runs/observe-declarations` | run token + verifier key (supervisor) |
32
- * | `POST /observe/finalize` | run token + verifier key (supervisor) |
33
- * | `POST /witness/server-persistence` | run token + verifier key (supervisor; |
34
- * | | the drain forwards intents — the suite |
35
- * | | can only WRITE spool lines) |
36
- * | `GET /runs/execution-trace` | run token + verifier key (supervisor) |
37
- * | `POST /sessions/open` | run token + verifier key (supervisor); |
38
- * | | test must be in the registered set |
39
- * | `POST /sessions/close` | run token + verifier key (supervisor) |
40
- *
41
- * Endpoints:
42
- *
43
- * - `POST /runs/expected-set` — SUPERVISOR ONLY: registers the expected
44
- * test set BEFORE the run (enforcement-review fix 2a), bound to this
45
- * run; identical re-registration is idempotent, any change or late
46
- * registration is 409. Returns the domain-separated enumeration digest.
47
- * - `POST /sessions/open` — SUPERVISOR ONLY: registers one started
48
- * test as a session binding (runId, sessionId, testId, worker). One
49
- * OPEN session per worker; an identical open (worker, testId)
50
- * re-binds idempotently. When an expected set is registered, the test
51
- * must belong to it (an invented testId is refused typed).
52
- * - `POST /sessions/close` — SUPERVISOR ONLY: seals the session with
53
- * the observed outcome. Closing SEALS: every later submission for the
54
- * session is rejected (no post-hoc record injection).
55
- * - `GET /runs/execution-trace` — SUPERVISOR ONLY: the witness-side
56
- * session record (enforcement-review fix 2b) — per expected test,
57
- * every session with its open/seal ticks and outcome. THE execution
58
- * authority supervision grades completeness from.
59
- * - `POST /sessions/resolve` — the worker-side fixture proves WHICH open
60
- * session it runs under by the exact (workerIndex, testId) pair; only
61
- * an open session answers, with the session credential.
62
- * - `POST /sessions/intervals/{open,close}` — the fixture marks a
63
- * witness-recorded observation interval per UI action (start/end ticks
64
- * from the witness's monotonic clock). Proxy exchanges observed
65
- * OUTSIDE every interval of a session are never consumable as that
66
- * session's evidence — direct setup traffic cannot become browser
67
- * evidence.
68
- * - `POST /records` — test-side primitives submit UI evidence
69
- * ({claimId, kind, payload, testId, sessionId, sessionToken}) → the
70
- * witness ISSUES a record with service-computed provenance ONLY under
71
- * a valid OPEN session (the record's testId is forced to the
72
- * session's supervisor-registered value; a mismatching testId is
73
- * refused). Unknown primitive kinds → 400 (GF-11, GF-14).
74
- * - `POST /witness/persistence` — the fixture asks the witness to run
75
- * the engine-side adapter (GET-only) for one entity → the witness
76
- * executes the read, stamps a `persistence.entity` record from the
77
- * ADAPTER RESPONSE, and returns {recordId, runId, verdictRelevant}.
78
- * Raw adapter bodies never cross back into the test process. Wire is
79
- * the pin-#7 shape extended with `testId` + `claimId` + the session
80
- * credential so persistence records bind to the claim the engine
81
- * grades, under the session the supervisor opened.
82
- * - `POST /witness/server-persistence` — SUPERVISOR ONLY: the trusted
83
- * drain forwards one persistence claim INTENT (drained from the
84
- * runner-side `persistence-intents.jsonl` spool the supervised suite
85
- * may only WRITE); the witness executes the resource's adapter SERVER
86
- * PROBE itself (behind the same attestation chain as every adapter
87
- * read) and stamps a WITNESSED `persistence.entity` record carrying
88
- * `channel: 'server'` + `declaredKind: 'server-e2e'` — the
89
- * server-witnessed channel for backend-only tables (a transactional
90
- * outbox) that can never honestly appear in a UI. Probes run ONLY in
91
- * this trusted process; missing adapter/probe/declaration and replayed
92
- * sequences resolve to typed failures, never to satisfaction.
93
- * - `POST /runs/observe-declarations` — SUPERVISOR ONLY: registers the
94
- * `observed-e2e` obligations BEFORE the run (same bind-once contract
95
- * as the server-e2e set).
96
- * - `POST /observe/finalize` — SUPERVISOR ONLY: resolves one OPEN
97
- * session's observe-declared claims against its own proxied traffic
98
- * plus independent adapter reads; stamps witnessed
99
- * `persistence.observed` records (`channel: 'observe'`) for whatever
100
- * resolves. Non-resolutions are typed notes — never satisfaction,
101
- * never a run failure.
102
- * - `POST /witness/http-observation` — consumes one engine-observed
103
- * proxied exchange for an http:* claim. Phase 1: the caller must hold
104
- * a valid OPEN session and the exchange must have been observed
105
- * through THAT session's proxy prefix WITHIN one of its recorded
106
- * action intervals — another test's/worker's request can never
107
- * satisfy a claim (E11/E12 foundation).
108
- * - `GET /records` — the issued ledger (the ONLY input the
109
- * reporter copies into `records.json`; fabricated bundles never enter
110
- * it — GF-23).
111
- * - `GET /classifications` — per-resource primaryKey/exposure/plane/
112
- * lifecycle projection for the reporter's per-claim ledger.
113
- * - `GET /health` — readiness + attestation scope identity.
114
- *
115
- * Attestation (GF-10, GF-13): the attestation subject and every adapter
116
- * read base must be loopback; adapter bases must present an
117
- * `x-gateforge-env-fingerprint` marker matching the adapter's declared
118
- * fingerprint (and the run's pinned target fingerprint when set).
119
- * Mismatches REJECT the record — never `satisfied`.
120
- *
121
- * At shutdown the witness appends the record ids it issued to
122
- * `manifest.json` in the run-state dir (pin #4/#7).
123
- */
124
- import { createServer, request } from 'node:http';
125
- import { createHash, randomUUID } from 'node:crypto';
126
- import { readFileSync, writeFileSync } from 'node:fs';
127
- import { join, resolve } from 'node:path';
128
- import { ATTESTATION_VERSION, attestationMac, behaviorActionDigestOf, BehaviorCatalogSchema, BEHAVIOR_CASE_KIND, enumerationDigestOf, interpretObservedPath, pathMatchesShape, recordIdOf, resolveHttpRoute, } from '@gate-forge/core';
129
- import { canonicalOf } from '../json.js';
130
- import { DEFAULT_REQUEST_TIMEOUT_MS, KNOWN_RECORD_KINDS, LOOPBACK_HOSTNAME, OBSERVED_KIND, PERSISTENCE_KIND, RUN_HEADER, VERIFIER_HEADER, } from '../constants.js';
131
- import { loadAdapters, makeAdapterContext } from './adapter-registry.js';
132
- import { AttestationError, assertLoopback, envFingerprintMismatch, probeEnvFingerprint, } from './env-attestation.js';
133
- import { hostResolverRules, pinnedLoopbackIps, pinnedGet } from './loopback-pins.js';
134
- import { loadClassifications, toClassificationView } from './classifications.js';
135
- import { EngineBrowserError, EngineBrowserManager, driveEngineAction, readEngineVisible, } from './browser.js';
136
- import { SURFACE_DESCRIPTOR_VERSION, validateSurface, } from '../surface.js';
137
- import { validateScopeSnapshot } from './behavior.js';
138
- import { BEHAVIOR_BODY_LIMIT_BYTES, BehaviorDriverError, driveBehaviorRequest } from './behavior-request.js';
139
- import { OBSERVE_CHANNEL, OBSERVED_E2E_TEST_KIND, SERVER_CHANNEL, SERVER_E2E_TEST_KIND } from '../constants.js';
140
- const MAX_BODY_BYTES = 1024 * 1024;
141
- const OBLIGATION_ID_PATTERN = /^[^:]+:.+$/;
142
- /**
143
- * Bounded response snapshot the observation proxy keeps per forwarded
144
- * exchange: at most this many body bytes are hashed into the snapshot,
145
- * while the TOTAL byte count is tracked separately. The response still
146
- * streams to the browser unbuffered — the snapshot is a tap, not a gate.
147
- */
148
- const OBSERVED_BODY_SNAPSHOT_BYTES = 16384;
149
- /**
150
- * Bounded request-body snapshot the observation proxy keeps per
151
- * forwarded exchange (Observe channel, Phase 2): the request body is
152
- * already buffered for forwarding, so retaining a capped copy costs one
153
- * slice. Bodies beyond the cap are flagged truncated — an observe
154
- * finalize can never echo what it cannot see, so oversized intents
155
- * grade typed-missing instead of satisfying on a prefix. Binary-safe:
156
- * stored raw; the finalize path parses JSON/form text from it.
157
- */
158
- const OBSERVED_REQUEST_BODY_BYTES = 65536;
159
- /** Fail-closed witness configuration/startup error. */
160
- export class WitnessStartupError extends Error {
161
- constructor(message) {
162
- super(message);
163
- this.name = 'WitnessStartupError';
164
- }
165
- }
166
- /** An HTTP JSON error the witness answers (status + {error, detail?}). */
167
- class HttpError extends Error {
168
- status;
169
- detail;
170
- constructor(status, message, detail = null) {
171
- super(message);
172
- this.name = 'HttpError';
173
- this.status = status;
174
- this.detail = detail;
175
- }
176
- }
177
- /** Codepoint-wise comparison for deterministic ordering. */
178
- function compareStrings(a, b) {
179
- return a < b ? -1 : a > b ? 1 : 0;
180
- }
181
- /** The expected-set identity join key (project, file, titlePath). */
182
- function expectedKey(project, file, titlePath) {
183
- return `${project ?? '-'}\u0000${file}\u0000${titlePath.join('>')}`;
184
- }
185
- /**
186
- * Normalizes the declared observation-proxy mount prefix (null when
187
- * unset/empty). Fail-closed on values that can never be a plain path
188
- * prefix — a malformed declaration would otherwise silently mismatch
189
- * obligation identities, the exact failure the option exists to prevent.
190
- */
191
- function normalizeMountPath(raw) {
192
- if (raw === null || raw === undefined || raw === '')
193
- return null;
194
- let path = raw.trim();
195
- if (!path.startsWith('/'))
196
- path = `/${path}`;
197
- if (path.length > 1)
198
- path = path.replace(/\/+$/, '');
199
- if (path === '/' || /[\s?#]/.test(path)) {
200
- throw new WitnessStartupError(`invalid mountPath '${raw}': declare the browser-facing mount prefix as a non-empty ` +
201
- "absolute path like '/api'");
202
- }
203
- return path;
204
- }
205
- /**
206
- * Lowercases a request content-type header to its media type without
207
- * parameters (`'Application/JSON; charset=utf-8'` → `'application/json'`),
208
- * or null when absent/unparseable. The Observe finalize path uses it to
209
- * decide body parsing (JSON vs form); anything else is ineligible.
210
- */
211
- function contentTypeOf(raw) {
212
- const first = Array.isArray(raw) ? raw[0] : raw;
213
- if (typeof first !== 'string')
214
- return null;
215
- const media = first.split(';')[0]?.trim().toLowerCase() ?? '';
216
- return media.length > 0 ? media : null;
217
- }
218
- /**
219
- * Strips the declared mount prefix from a proxied request URL (path
220
- * plus possible query/fragment), returning the backend-facing URL the
221
- * proxy forwards AND records. A request outside the prefix passes
222
- * through untouched, and with no declared prefix the URL is returned
223
- * byte-identical (the unmounted proxy's behavior).
224
- */
225
- function stripMountPath(rawUrl, mountPath) {
226
- if (mountPath === null)
227
- return rawUrl;
228
- const queryStart = rawUrl.search(/[?#]/);
229
- const pathPart = queryStart === -1 ? rawUrl : rawUrl.slice(0, queryStart);
230
- const suffix = queryStart === -1 ? '' : rawUrl.slice(queryStart);
231
- if (pathPart === mountPath)
232
- return `/${suffix}`;
233
- if (pathPart.startsWith(`${mountPath}/`)) {
234
- return `${pathPart.slice(mountPath.length)}${suffix}`;
235
- }
236
- return rawUrl;
237
- }
238
- /**
239
- * Starts one loopback reverse-proxy server forwarding to the run's
240
- * attested proxy target. `sessionId` names the session the port belongs
241
- * to (null = the shared unattributed proxy): every exchange completing
242
- * on this port is recorded as an engine observation stamped with that
243
- * session id and the witness-monotonic completion tick.
244
- *
245
- * Args:
246
- * state: running witness state.
247
- * sessionId: owning session id, or null for the shared proxy.
248
- *
249
- * Returns:
250
- * Promise<Server>: the listening server (OS-assigned loopback port).
251
- *
252
- * Throws:
253
- * Error: when the server fails to bind.
254
- */
255
- async function startObservedProxy(state, sessionId) {
256
- const proxyTargetUrl = new URL(state.options.proxyTarget);
257
- const server = createServer((req, res) => {
258
- const chunks = [];
259
- req.on('data', (chunk) => chunks.push(chunk));
260
- req.on('end', () => {
261
- const body = Buffer.concat(chunks);
262
- // Mount-prefix handling: forward the backend-facing (STRIPPED)
263
- // URL, and record the same STRIPPED path below, so observations
264
- // match the backend-derived obligation identities the suite
265
- // claims. With no declared mount path the URL is forwarded and
266
- // recorded byte-identical to today.
267
- const forwardUrl = stripMountPath(req.url ?? '/', state.options.mountPath);
268
- // In-flight accounting (plan §11.4): a proxy exchange that starts
269
- // before `/run-context` binds must refuse the bind — otherwise
270
- // traffic from an older invocation could be signed under the new
271
- // context.
272
- state.proxyInFlight += 1;
273
- let settledFlight = false;
274
- const settleFlight = () => {
275
- if (!settledFlight) {
276
- settledFlight = true;
277
- state.proxyInFlight -= 1;
278
- }
279
- };
280
- // b59/b60 lesson (phase7-runtime e22ec24): `agent: false` is
281
- // load-bearing. On Node >=19 the default global agent keeps sockets
282
- // alive while dev servers close idle keep-alive sockets at their
283
- // keepAliveTimeout — reusing a socket the target closed mid-handshake
284
- // intermittently killed exactly one browser exchange per batch. A
285
- // fresh loopback connection per forwarded exchange costs nothing and
286
- // removes the reuse race.
287
- const forward = request({
288
- protocol: proxyTargetUrl.protocol,
289
- hostname: proxyTargetUrl.hostname,
290
- port: proxyTargetUrl.port,
291
- method: req.method,
292
- path: forwardUrl,
293
- headers: { ...req.headers, host: proxyTargetUrl.host },
294
- agent: false,
295
- }, (upstream) => {
296
- const status = upstream.statusCode ?? 0;
297
- const observedPath = normalizeObservedPath(forwardUrl);
298
- // Bounded response-body snapshot: the tap is attached BEFORE
299
- // piping so both consumers receive the stream; forwarding to
300
- // the browser stays unbuffered (the snapshot never gates the
301
- // response). Total bytes are counted even beyond the snapshot
302
- // limit; only the snapshot is hashed.
303
- const snapshot = [];
304
- let snapshotBytes = 0;
305
- let totalBytes = 0;
306
- upstream.on('data', (chunk) => {
307
- totalBytes += chunk.length;
308
- if (snapshotBytes < OBSERVED_BODY_SNAPSHOT_BYTES) {
309
- const room = OBSERVED_BODY_SNAPSHOT_BYTES - snapshotBytes;
310
- const taken = chunk.length > room ? chunk.subarray(0, room) : chunk;
311
- snapshot.push(Buffer.from(taken)); // copy: detach from the stream pool
312
- snapshotBytes += taken.length;
313
- }
314
- });
315
- upstream.on('end', () => {
316
- state.observed.push({
317
- method: (req.method ?? 'GET').toUpperCase(),
318
- path: observedPath,
319
- status,
320
- seq: (state.observedSeq += 1),
321
- bodySha256: createHash('sha256').update(Buffer.concat(snapshot)).digest('hex'),
322
- bodyBytes: totalBytes,
323
- requestBody: body.length === 0 ? null : Buffer.from(body.subarray(0, OBSERVED_REQUEST_BODY_BYTES)),
324
- requestTruncated: body.length > OBSERVED_REQUEST_BODY_BYTES,
325
- requestBytes: body.length,
326
- requestContentType: contentTypeOf(req.headers['content-type']),
327
- sessionId,
328
- tick: (state.tick += 1),
329
- });
330
- settleFlight();
331
- });
332
- upstream.on('error', settleFlight);
333
- res.writeHead(status, upstream.headers);
334
- upstream.pipe(res);
335
- });
336
- forward.on('error', () => {
337
- settleFlight();
338
- if (!res.headersSent)
339
- sendJson(res, 502, { error: 'observation proxy upstream failed' });
340
- else
341
- res.end();
342
- });
343
- if (body.length > 0)
344
- forward.write(body);
345
- forward.end();
346
- });
347
- });
348
- await new Promise((resolveListen, rejectListen) => {
349
- server.once('error', rejectListen);
350
- server.listen(0, state.options.host, () => resolveListen());
351
- });
352
- server.removeAllListeners('error');
353
- return server;
354
- }
355
- /** The base URL of a started observation-proxy server. */
356
- function proxyUrlOf(state, server) {
357
- const address = server.address();
358
- if (address === null || typeof address === 'string') {
359
- throw new WitnessStartupError('observation proxy failed to bind an OS-assigned port');
360
- }
361
- return `http://${formatHost(state.options.host)}:${address.port}`;
362
- }
363
- /**
364
- * Starts the DEDICATED session proxy port (plan Phase 1) and stores it
365
- * on the session. Only called when the run wires an observation proxy;
366
- * the worker's browser uses this origin for the whole test, so all of
367
- * its traffic — absolute paths included — is attributed to the session.
368
- *
369
- * Args:
370
- * state: running witness state.
371
- * session: the freshly opened session.
372
- */
373
- async function startSessionProxy(state, session) {
374
- if (typeof state.options.proxyTarget !== 'string' ||
375
- state.options.proxyTarget.length === 0) {
376
- session.proxyUrl = null;
377
- return;
378
- }
379
- const server = await startObservedProxy(state, session.sessionId);
380
- session.proxyServer = server;
381
- session.proxyUrl = proxyUrlOf(state, server);
382
- }
383
- /** Closes one session's dedicated proxy port (sealed = channel gone). */
384
- async function stopSessionProxy(session) {
385
- const server = session.proxyServer;
386
- session.proxyServer = null;
387
- if (server === null)
388
- return;
389
- await new Promise((resolveClose) => {
390
- server.close(() => resolveClose());
391
- });
392
- }
393
- function isPlainObject(value) {
394
- return typeof value === 'object' && value !== null && !Array.isArray(value);
395
- }
396
- /**
397
- * The service-issued recordId comes from the frozen core primitive
398
- * (`recordIdOf`, pin #1/#7): sha256 over GF-canonical JSON of the record
399
- * identity. Sharing one implementation with the engine's provenance
400
- * verifier guarantees the witness issues exactly what evaluation can
401
- * recompute — an entry that never passed through the service has no
402
- * matching hash, so shape-level fabrication (a hex string the service
403
- * never issued) cannot line up with the ledger the reporter copies.
404
- */
405
- export { recordIdOf };
406
- /** Reads the JSON request body (sized; malformed → HttpError). */
407
- function readBody(req) {
408
- return new Promise((resolveBody, rejectBody) => {
409
- let raw = '';
410
- req.setEncoding('utf8');
411
- req.on('data', (chunk) => {
412
- raw += chunk;
413
- if (raw.length > MAX_BODY_BYTES) {
414
- rejectBody(new HttpError(400, 'request body too large'));
415
- req.destroy();
416
- }
417
- });
418
- req.on('end', () => {
419
- if (raw.length === 0) {
420
- rejectBody(new HttpError(400, 'request body is required'));
421
- return;
422
- }
423
- try {
424
- resolveBody(JSON.parse(raw));
425
- }
426
- catch (error) {
427
- rejectBody(new HttpError(400, `request body is not valid JSON: ${error.message}`));
428
- }
429
- });
430
- req.on('error', rejectBody);
431
- });
432
- }
433
- /**
434
- * Starts the witness service.
435
- *
436
- * Args:
437
- * options: run identity + token (required), state dir, adapters dir,
438
- * classifications path, target/attestation config, timeout, clock.
439
- *
440
- * Returns:
441
- * WitnessHandle: {url, stop} once the server listens.
442
- *
443
- * Throws:
444
- * WitnessStartupError / AdapterRegistryError / AttestationError:
445
- * fail-closed startup problems (GF-10 blocks non-loopback targets
446
- * HERE, before any adapter request can be constructed).
447
- */
448
- export async function startWitness(options) {
449
- if (typeof options.runId !== 'string' || options.runId.length === 0) {
450
- throw new WitnessStartupError('witness requires a runId (GATEFORGE_RUN_ID)');
451
- }
452
- if (typeof options.token !== 'string' || options.token.length === 0) {
453
- throw new WitnessStartupError('witness requires a token (GATEFORGE_RUN_TOKEN)');
454
- }
455
- const cwd = process.cwd();
456
- const adaptersDir = options.adaptersDir === null || options.adaptersDir === undefined
457
- ? null
458
- : resolve(cwd, options.adaptersDir);
459
- const classificationsPath = options.classificationsPath === null || options.classificationsPath === undefined
460
- ? null
461
- : resolve(cwd, options.classificationsPath);
462
- const adapters = adaptersDir === null ? new Map() : await loadAdapters(adaptersDir);
463
- const classifications = loadClassifications(classificationsPath);
464
- const targetBaseUrl = options.targetBaseUrl ?? null;
465
- // The mount prefix declares how the browser-facing deployment mounts
466
- // the backend for the OBSERVATION PROXY; it is meaningless without one.
467
- const mountPath = normalizeMountPath(options.mountPath);
468
- if (mountPath !== null && (options.proxyTarget === undefined || options.proxyTarget === '')) {
469
- throw new WitnessStartupError('witness option mountPath requires proxyTarget: the mount prefix declares how the ' +
470
- 'observation proxy bridges the browser-facing deployment and the backend');
471
- }
472
- // GF-10: the attestation subject must be loopback — block at startup,
473
- // before any mutation-capable request surface exists.
474
- if (targetBaseUrl !== null) {
475
- await assertLoopback(targetBaseUrl, 'attestation subject');
476
- }
477
- // GF-13 minimal v1: when the run pins a fingerprint, the subject's
478
- // marker must match before the service opens for business.
479
- if (targetBaseUrl !== null &&
480
- options.targetFingerprint !== null &&
481
- options.targetFingerprint !== undefined) {
482
- const probe = await probeEnvFingerprint(targetBaseUrl, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
483
- const mismatch = envFingerprintMismatch(probe, options.targetFingerprint, options.targetFingerprint);
484
- if (mismatch !== null) {
485
- throw new AttestationError(`attestation subject '${targetBaseUrl}' failed startup attestation: ${mismatch}`);
486
- }
487
- }
488
- const state = {
489
- options: {
490
- ...options,
491
- runId: options.runId,
492
- token: options.token,
493
- mountPath,
494
- verifierKey: typeof options.verifierKey === 'string' && options.verifierKey.length > 0
495
- ? options.verifierKey
496
- : null,
497
- requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
498
- host: options.host ?? LOOPBACK_HOSTNAME,
499
- },
500
- adapters,
501
- classifications,
502
- ledger: new Map(),
503
- preObservations: new Map(),
504
- serverE2eDeclarations: null,
505
- serverPreObservations: new Map(),
506
- serverIntentSequences: new Map(),
507
- observeDeclarations: null,
508
- observeSnapshots: new Map(),
509
- observed: [],
510
- observedSeq: 0,
511
- runContext: null,
512
- observedSeqAtBind: 0,
513
- proxyInFlight: 0,
514
- expectedTests: new Map(),
515
- enumerationDigest: null,
516
- behaviorCatalog: null,
517
- caseExecutions: new Map(),
518
- tick: 0,
519
- sessions: new Map(),
520
- workerSessions: new Map(),
521
- engineBrowser: new EngineBrowserManager(options.engineBrowserLauncher),
522
- proxyServer: null,
523
- server: undefined,
524
- nowIso: options.now ?? (() => new Date().toISOString()),
525
- stopped: false,
526
- };
527
- state.server = createServer((req, res) => {
528
- void handleRequest(state, req, res);
529
- });
530
- await new Promise((resolveListen, rejectListen) => {
531
- state.server.once('error', rejectListen);
532
- state.server.listen(0, state.options.host, () => resolveListen());
533
- });
534
- state.server.removeAllListeners('error');
535
- const address = state.server.address();
536
- if (address === null || typeof address === 'string') {
537
- await stopWitness(state);
538
- throw new WitnessStartupError('witness failed to bind an OS-assigned port');
539
- }
540
- const url = `http://${formatHost(state.options.host)}:${address.port}`;
541
- // ADR 0004 D7: the witness-owned loopback reverse proxy. Traffic
542
- // aimed at a witness proxy is forwarded to the attested target and
543
- // (method, path, status, bounded body snapshot, total body bytes)
544
- // recorded as an ENGINE observation; a suite-callable endpoint consumes
545
- // a matching observation to issue a witnessed record. A proxy never
546
- // needs the run token: it serves the browser, holds no authority, and
547
- // can only add observations the engine itself saw.
548
- //
549
- // Phase 1 session channels: the shared proxy (below) stays the
550
- // UNATTRIBUTED channel — its exchanges carry sessionId null and are
551
- // never consumable as a test's evidence. Each supervisor-opened
552
- // session additionally gets a DEDICATED loopback proxy port (see
553
- // `startSessionProxy`): the worker's browser uses that origin for the
554
- // whole test, so every absolute-path form action, link, and fetch on
555
- // it lands on the session's own channel — attribution by ORIGIN, not
556
- // by URL rewriting.
557
- let proxyUrl = null;
558
- if (typeof state.options.proxyTarget === 'string' && state.options.proxyTarget.length > 0) {
559
- await assertLoopback(state.options.proxyTarget, 'observation proxy target');
560
- state.proxyServer = await startObservedProxy(state, null);
561
- proxyUrl = proxyUrlOf(state, state.proxyServer);
562
- }
563
- // DNS binding (loopback-pins): the startup asserts above pinned every
564
- // operator-provided hostname to its approved loopback IPs. Hand the
565
- // resulting resolver rules to the engine browser BEFORE it can launch —
566
- // its traffic for those names then cannot leave loopback even if DNS
567
- // changes mid-run, while Host headers and origins (tenant routing)
568
- // stay exactly as the suite addresses them.
569
- state.engineBrowser.setDnsPinRules(hostResolverRules(pinnedLoopbackIps()));
570
- const handle = Object.freeze({
571
- url,
572
- proxyUrl,
573
- stop: () => stopWitness(state),
574
- });
575
- return handle;
576
- }
577
- /**
578
- * Canonicalizes an observed request path (query/fragment stripped, one
579
- * leading slash, trailing slashes dropped, root '/' stays '/').
580
- * Lockstep with core's `interpretObservedPath` (plan §9 steps 1-3):
581
- * duplicate slashes are NOT collapsed and percent-encodings are NEVER
582
- * decoded on either side — noncanonical routing meaning stays visible
583
- * so the verifier blocks instead of matching a different endpoint.
10
+ * ONE behaviour lives here rather than in the neutral package: the
11
+ * Chromium default for the engine browser. The runner-neutral witness
12
+ * never assumes a browser, so `startWitness` there requires an
13
+ * `EngineBrowserLauncher`; this package is the Playwright layer, so it
14
+ * supplies pinned Chromium when the caller names none. Every other
15
+ * witness behaviour is byte-identical.
584
16
  */
585
- function normalizeObservedPath(rawPath) {
586
- let path = rawPath.split('?')[0]?.split('#')[0] ?? '/';
587
- if (!path.startsWith('/'))
588
- path = `/${path}`;
589
- if (path.length > 1)
590
- path = path.replace(/\/+$/, '');
591
- return path;
592
- }
593
- /**
594
- * Consumes one engine-observed request matching (method, path) and
595
- * issues witnessed `http.request` records bound to the declaring test's
596
- * obligation claims (ADR 0004 D7, plan §8 / D1 transport-only semantics).
597
- * The witness observes that an HTTP exchange traversed the proxy; WHICH
598
- * browser, UI action, or test produced it is suite-claimed attribution,
599
- * never independent proof. Single-use at the EXCHANGE level: an
600
- * observation proves exactly one real request — it is consumed on first
601
- * match and can never be re-claimed, replayed, or extended later. One
602
- * genuine exchange genuinely instantiates every contract its endpoint
603
- * declares of it (a compiler emits `http:frontend-request-observed` AND
604
- * `http:response-status-ok` per consumed endpoint; ADR 0004 D8 calls the
605
- * latter "the same witnessed record carrying a 2xx status"), so the
606
- * consumed exchange issues one record PER claim id the declaring test
607
- * itself declared — all carrying the identical engine-observed payload,
608
- * each still independently provenance-verified and shape/status-checked
609
- * by the verdict engine. The payload is
610
- * `{method, url, status, bodySha256, bodyBytes}` — the bounded response
611
- * snapshot hash and total byte count ride in the record, a tamper-evident
612
- * trace of exactly what the engine observed.
613
- *
614
- * An optional `expectedStatus` narrows the consume match to exchanges
615
- * the target answered with that exact status. This stays honest: the
616
- * suite still cannot fabricate or mutate observations — it only selects
617
- * WHICH real exchange it is accounting for. It exists because one
618
- * (method, path) shape can legitimately fire several times per run with
619
- * different statuses (e.g. the SPA's unauthenticated `/me` probe ahead of
620
- * the authenticated one); FIFO-without-status would bind a `:response-
621
- * status-ok` claim to an observed 401 the journey never intended.
622
- */
623
- async function handleHttpObservation(state, res, body) {
624
- const testId = body['testId'];
625
- const method = body['method'];
626
- const path = body['path'];
627
- const expectedStatus = body['expectedStatus'];
628
- // A split legacy assignment (distinct `claimId` vs `obligationId`)
629
- // is ambiguous caller intent — fail closed instead of silently
630
- // picking one (plan §8 step 7: no silent wrong-obligation binding).
631
- if (typeof body['claimId'] === 'string' &&
632
- body['claimId'].length > 0 &&
633
- typeof body['obligationId'] === 'string' &&
634
- body['obligationId'].length > 0 &&
635
- body['claimId'] !== body['obligationId']) {
636
- sendJson(res, 400, {
637
- error: 'http observation refuses a split claimId/obligationId assignment: supply one ' +
638
- 'explicit obligation id (or a claimIds list)',
639
- });
640
- return;
641
- }
642
- // Claim binding: `claimIds` (the declaring test's claimed obligation
643
- // ids for this endpoint) — with the singular legacy `claimId` /
644
- // `obligationId` pair still accepted and folded in.
645
- const rawClaimIds = Array.isArray(body['claimIds'])
646
- ? [...body['claimIds'], body['claimId'], body['obligationId']]
647
- : [body['claimIds'], body['claimId'], body['obligationId']];
648
- const claimIds = [];
649
- for (const entry of rawClaimIds) {
650
- if (entry === undefined || entry === null)
651
- continue;
652
- if (typeof entry !== 'string' || !OBLIGATION_ID_PATTERN.test(entry)) {
653
- sendJson(res, 400, {
654
- error: 'http observation requires claimIds as obligation-id strings ' +
655
- "'<resourceId>:<contract>' (a singular legacy claimId/obligationId is still accepted)",
656
- });
657
- return;
658
- }
659
- if (!claimIds.includes(entry))
660
- claimIds.push(entry);
661
- }
662
- if (claimIds.length === 0 ||
663
- typeof testId !== 'string' ||
664
- testId.length === 0 ||
665
- typeof method !== 'string' ||
666
- typeof path !== 'string' ||
667
- path.length === 0 ||
668
- (expectedStatus !== undefined &&
669
- (typeof expectedStatus !== 'number' || !Number.isInteger(expectedStatus)))) {
670
- sendJson(res, 400, {
671
- error: 'http observation requires testId, method, and path strings plus at least one ' +
672
- "claimed obligation id ('<resourceId>:<contract>'); expectedStatus, when present, " +
673
- 'must be an integer status code',
674
- });
675
- return;
676
- }
677
- const wanted = normalizeObservedPath(path);
678
- // Phase 1 (E11/E12 foundation): the consuming side must hold a valid
679
- // OPEN session, and only an exchange observed through THAT session's
680
- // proxy prefix WITHIN one of its recorded action intervals can be
681
- // consumed — a request supplied by another test/worker (different
682
- // session channel) or by setup traffic outside every interval is
683
- // never credited to this test's claims.
684
- const session = requireOpenSession(state, body);
685
- requireSessionTestId(session, testId);
686
- // Bind watermark (plan §11.4): observations that completed before the
687
- // trusted context bound predate it and are never consumable under the
688
- // new invocation — closing the proxy/bind race where a request started
689
- // before binding but its response ends after it.
690
- const watermark = state.runContext === null ? 0 : state.observedSeqAtBind;
691
- const matchesShape = (entry) => entry.seq > watermark &&
692
- entry.method === method.toUpperCase() &&
693
- entry.path === wanted &&
694
- (expectedStatus === undefined || entry.status === expectedStatus);
695
- const index = state.observed.findIndex((entry) => entry.sessionId === session.sessionId &&
696
- tickWithinSessionInterval(session, entry.tick) &&
697
- matchesShape(entry));
698
- if (index === -1) {
699
- // Precise fail-closed diagnostics: distinguish "outside every
700
- // interval" from "another session's channel" from "no such traffic".
701
- const unattributed = state.observed.find((entry) => entry.sessionId === null && matchesShape(entry));
702
- const foreign = state.observed.find((entry) => entry.sessionId !== null && entry.sessionId !== session.sessionId && matchesShape(entry));
703
- const outsideInterval = state.observed.find((entry) => entry.sessionId === session.sessionId && matchesShape(entry));
704
- if (outsideInterval !== undefined) {
705
- sendJson(res, 409, {
706
- error: `an engine-observed ${method.toUpperCase()} ${wanted} exists for this session but its ` +
707
- 'completion tick falls outside every recorded UI-action observation interval — the ' +
708
- 'fixture marks an interval per UI action, so traffic outside those windows (setup ' +
709
- 'calls, stray navigation) is never browser evidence',
710
- });
711
- return;
712
- }
713
- if (foreign !== undefined) {
714
- sendJson(res, 409, {
715
- error: `the engine-observed ${method.toUpperCase()} ${wanted} traversed ANOTHER session's ` +
716
- 'channel; exchanges are consumable only by the session whose proxy prefix they ' +
717
- 'arrived through (a request supplied by another test/worker is never credited)',
718
- });
719
- return;
720
- }
721
- sendJson(res, 409, {
722
- error: `no engine-observed request matches ${method.toUpperCase()} ${wanted}` +
723
- `${expectedStatus === undefined ? '' : ` with status ${String(expectedStatus)}`}` +
724
- `${unattributed === undefined ? '' : ' (traffic bypassed every session channel)'}; drive ` +
725
- 'traffic through this session\'s observation-proxy prefix before claiming the obligation',
726
- });
727
- return;
728
- }
729
- const observedRequest = state.observed[index];
730
- // Consume the exchange FIRST (single-use), then issue one record per
731
- // distinct claimed obligation id — same payload, per-claim identity.
732
- state.observed.splice(index, 1);
733
- // Witness-side activity (review recheck fix 2026-09-14): consuming an
734
- // engine-observed session exchange is an observation the witness made;
735
- // count it.
736
- session.activity += 1;
737
- const payload = {
738
- method: observedRequest.method,
739
- url: observedRequest.path,
740
- status: observedRequest.status,
741
- bodySha256: observedRequest.bodySha256,
742
- bodyBytes: observedRequest.bodyBytes,
743
- sessionId: session.sessionId,
744
- };
745
- const issued = claimIds.map((claimId) => issueRecord(state, claimId, 'http.request', session.testId, payload, 'engine-observed'));
746
- const first = issued[0];
747
- sendJson(res, 200, {
748
- recordId: first.recordId,
749
- runId: first.runId,
750
- trust: first.trust,
751
- status: observedRequest.status,
752
- records: issued.map((record) => ({ recordId: record.recordId, obligationId: record.obligationId })),
753
- });
754
- }
17
+ import { chromium } from 'playwright';
18
+ import { startWitness as startRunnerNeutralWitness, } from '@gate-forge/witness/witness/server';
19
+ export * from '@gate-forge/witness/witness/server';
755
20
  /**
756
- * Resolves the engine browser's UI subject — the ONE attested
757
- * application origin the engine may drive (fake-frontend fix
758
- * 2026-09-14): `targetBaseUrl`, provisioned through trusted witness
759
- * configuration (orchestrator env/flags), never from suite input. A
760
- * copyable fingerprint header cannot authenticate application identity,
761
- * so origin equality with this provisioned subject is the identity —
762
- * loopback + fingerprint remain as attestation defense in depth, never
763
- * as identity.
21
+ * Starts the loopback witness with the engine browser defaulted to
22
+ * pinned Chromium.
764
23
  *
765
24
  * Args:
766
- * state: running witness state.
25
+ * options: witness options; `engineBrowserLauncher` defaults to
26
+ * Playwright's `chromium` when absent.
767
27
  *
768
28
  * Returns:
769
- * string: the normalized trusted base (no trailing slash).
770
- *
771
- * Throws:
772
- * HttpError: 409 when no attested subject is provisioned (browser
773
- * proof without a provisioned subject fails closed — it never
774
- * falls back to a suite-supplied origin).
29
+ * Promise<WitnessHandle>: the running witness handle.
775
30
  */
776
- function requireTrustedUiBase(state) {
777
- const base = state.options.targetBaseUrl;
778
- if (base === null || base === undefined || base === '') {
779
- throw new HttpError(409, 'no attested UI subject is provisioned for this witness (targetBaseUrl): browser proof ' +
780
- 'requires the orchestrator to provision the application origin through trusted ' +
781
- 'configuration — the engine never drives a suite-supplied origin');
782
- }
783
- return base.replace(/\/+$/, '');
784
- }
785
- /**
786
- * `POST /browser/surface` (plan Phase 1 item 4): registers the
787
- * consumer-declared surface descriptor for one open session. The
788
- * descriptor is validated structurally engine-side; selectors are
789
- * locators only — registration proves nothing by itself.
790
- *
791
- * The driven origin is NOT negotiable here (fake-frontend fix
792
- * 2026-09-14): a `appBaseUrl` field is rejected outright (400) — the
793
- * engine drives exactly the provisioned attested subject
794
- * (`requireTrustedUiBase`), which must be loopback (GF-10) and, when
795
- * the run pins a target fingerprint, must present it (GF-13). A test
796
- * that could name its own frontend could point the engine at a fake
797
- * that replays the real API — origin equality with trusted
798
- * configuration is the only application identity.
799
- */
800
- async function handleBrowserSurface(state, res, body) {
801
- if (!isPlainObject(body)) {
802
- throw new HttpError(400, 'browser surface body must be an object');
803
- }
804
- const { testId, surface } = body;
805
- if (typeof testId !== 'string' || testId.length === 0) {
806
- throw new HttpError(400, 'browser surface requires a non-empty testId');
807
- }
808
- if (body['appBaseUrl'] !== undefined) {
809
- throw new HttpError(400, 'browser surface rejects appBaseUrl: the engine drives exactly the provisioned attested ' +
810
- 'subject from trusted witness configuration — suite-supplied origins are never accepted ' +
811
- '(a test-named frontend could replay the real API behind a copied fingerprint header)');
812
- }
813
- const session = requireOpenSession(state, body);
814
- requireSessionTestId(session, testId);
815
- let validated;
816
- try {
817
- validated = validateSurface(surface);
818
- }
819
- catch (error) {
820
- throw new HttpError(400, `browser surface descriptor rejected: ${error.message}`);
821
- }
822
- // The trusted subject, resolved BEFORE any browser exists: loopback
823
- // attestation (GF-10/GF-13) runs against the provisioned origin, never
824
- // a suite URL.
825
- const trustedBase = requireTrustedUiBase(state);
826
- await assertLoopback(trustedBase, 'engine browser base');
827
- const pinned = state.options.targetFingerprint ?? null;
828
- if (pinned !== null) {
829
- const probe = await probeEnvFingerprint(trustedBase, state.options.requestTimeoutMs);
830
- const mismatch = envFingerprintMismatch(probe, pinned, pinned);
831
- if (mismatch !== null) {
832
- throw new HttpError(409, `engine browser subject rejected: ${mismatch}`);
833
- }
834
- }
835
- session.engineSurface = {
836
- surface: validated,
837
- };
838
- sendJson(res, 200, { registered: true });
839
- }
840
- /** Requires the session's registered engine surface (409 when absent). */
841
- function requireEngineSurface(session) {
842
- const registered = session.engineSurface;
843
- if (registered === null) {
844
- throw new HttpError(409, 'no engine surface is registered for this session: the fixture must register the ' +
845
- 'consumer-declared surface descriptor first (POST /browser/surface) — the engine ' +
846
- 'drives no browser without it');
847
- }
848
- return {
849
- surface: registered.surface,
850
- };
851
- }
852
- /** Validates browser claim ids: non-empty, obligation-shaped, one resource. */
853
- function requireBrowserClaims(body) {
854
- const raw = body['claimIds'];
855
- if (!Array.isArray(raw) || raw.length === 0) {
856
- throw new HttpError(400, 'browser calls require a non-empty claimIds array of obligation ids');
857
- }
858
- const claimIds = [];
859
- for (const entry of raw) {
860
- if (typeof entry !== 'string' || !OBLIGATION_ID_PATTERN.test(entry)) {
861
- throw new HttpError(400, `browser claimIds must be obligation ids '<resourceId>:<contract>' (got '${String(entry)}')`);
862
- }
863
- if (!claimIds.includes(entry))
864
- claimIds.push(entry);
865
- }
866
- const resources = new Set(claimIds.map((id) => id.slice(0, id.indexOf(':'))));
867
- if (resources.size !== 1) {
868
- throw new HttpError(400, 'browser calls bind one resource per action: claimIds span several resources ' +
869
- `(${[...resources].sort(compareStrings).join(', ')}) — drive one action per resource`);
870
- }
871
- return { claimIds, resourceId: [...resources][0] };
872
- }
873
- /**
874
- * `POST /browser/action` (plan Phase 1 item 4): performs ONE constrained
875
- * surface operation on the session's engine-owned page and issues
876
- * engine-observed records for exactly what the engine did. The full
877
- * binding in one call: the relevant rendered control + entered values,
878
- * the actual action, the resulting application request + entity
879
- * identity, the visible outcome — plus the engine-side pre-observation
880
- * the persistence read later consumes (create/update). The captured
881
- * exchanges join the witness observation log inside the engine's own
882
- * action interval, so the existing http-observation consume path binds
883
- * them with single-use semantics.
884
- */
885
- async function handleBrowserAction(state, res, body) {
886
- if (!isPlainObject(body)) {
887
- throw new HttpError(400, 'browser action body must be an object');
888
- }
889
- const { testId, operation, fields, entityId } = body;
890
- if (typeof testId !== 'string' || testId.length === 0) {
891
- throw new HttpError(400, 'browser action requires a non-empty testId');
892
- }
893
- if (operation !== 'create' && operation !== 'read' && operation !== 'update' && operation !== 'delete') {
894
- throw new HttpError(400, "browser action operation must be one of 'create' | 'read' | 'update' | 'delete'");
895
- }
896
- if (fields !== undefined && !isPlainObject(fields)) {
897
- throw new HttpError(400, 'browser action fields, when present, must be a string-valued object');
898
- }
899
- if (entityId !== undefined && typeof entityId !== 'string') {
900
- throw new HttpError(400, 'browser action entityId, when present, must be a string');
901
- }
902
- const session = requireOpenSession(state, body);
903
- const boundTestId = requireSessionTestId(session, testId);
904
- const { claimIds, resourceId } = requireBrowserClaims(body);
905
- const { surface } = requireEngineSurface(session);
906
- // The driven origin comes from trusted configuration on EVERY call —
907
- // never from stored suite input (there is none anymore).
908
- const appBaseUrl = requireTrustedUiBase(state);
909
- // The engine's own observation interval (witness clock, never suite
910
- // time): exchanges captured during the drive land inside it.
911
- const { intervalId } = openActionInterval(state, session, operation);
912
- // Engine-side pre-observation BEFORE the drive (create: id-set
913
- // absence; update: entity-fields delta) — the persistence read later
914
- // consumes it under the same session.
915
- let preObservationId = null;
916
- if (operation === 'create' || operation === 'update') {
917
- const pre = await takePreObservation(state, session, resourceId, operation === 'update' ? (entityId ?? '') : undefined);
918
- preObservationId = pre.observationId;
919
- }
920
- try {
921
- const page = await state.engineBrowser.pageFor(session.sessionId);
922
- const observation = await driveEngineAction(page, appBaseUrl, surface, operation, {
923
- ...(fields !== undefined ? { fields: fields } : {}),
924
- ...(entityId !== undefined ? { entityId } : {}),
925
- });
926
- // Publish the captured exchanges into the witness observation log
927
- // INSIDE the engine interval (ticks between open and close), so the
928
- // existing single-use http-observation consume path binds them.
929
- for (const exchange of observation.exchanges) {
930
- state.observed.push({
931
- method: exchange.method,
932
- path: exchange.path,
933
- status: exchange.status,
934
- seq: (state.observedSeq += 1),
935
- bodySha256: createHash('sha256').update(exchange.body).digest('hex'),
936
- bodyBytes: exchange.body.length,
937
- // Engine-captured exchanges carry no request body (the engine
938
- // typed the input; entered fields ride the ui.action record) —
939
- // they can never serve an observe finalize, which requires the
940
- // proxied request bytes.
941
- requestBody: null,
942
- requestTruncated: false,
943
- requestBytes: 0,
944
- requestContentType: null,
945
- sessionId: session.sessionId,
946
- tick: (state.tick += 1),
947
- });
948
- }
949
- // Suite-submittable intervals/records stay open-submission; the
950
- // ENGINE's own window closes here — late suite traffic after this
951
- // tick is outside the engine interval and never credited.
952
- closeActionInterval(state, session, intervalId);
953
- // Witness-side activity: the engine drove a real browser action
954
- // under the session; count it.
955
- session.activity += 1;
956
- // One engine-observed ui.action per claimed obligation (same
957
- // payload, per-claim identity — the http-observation convention).
958
- // The payload carries what the ENGINE observed: the operation, the
959
- // rendered entity id, and the ENTERED input (exact-value echo
960
- // source) — never suite-declared outcomes.
961
- const issued = claimIds.map((claimId) => issueRecord(state, claimId, 'ui.action', boundTestId, {
962
- operation,
963
- entityId: observation.entityId,
964
- fields: observation.enteredFields,
965
- sessionId: session.sessionId,
966
- }, 'engine-observed'));
967
- const appStatus = operation === 'read'
968
- ? (observation.exchanges[0]?.status ?? 0)
969
- : (observation.exchanges.find((entry) => entry.method !== 'GET' && entry.method !== 'HEAD' && entry.status < 400)?.status ?? 0);
970
- const response = {
971
- entityId: observation.entityId,
972
- enteredFields: observation.enteredFields,
973
- renderedFields: observation.renderedFields,
974
- appStatus,
975
- preObservationId,
976
- recordIds: issued.map((record) => record.recordId),
977
- };
978
- sendJson(res, 200, response);
979
- }
980
- catch (error) {
981
- // The drive failed AFTER the interval opened: seal the window so a
982
- // failed action never leaves a dangling interval for later traffic
983
- // to borrow, then fail the call (the test fails; the gate blocks).
984
- try {
985
- closeActionInterval(state, session, intervalId);
986
- }
987
- catch {
988
- // already closed — ignore
989
- }
990
- if (error instanceof EngineBrowserError) {
991
- throw new HttpError(409, `engine browser action failed: ${error.message}`);
992
- }
993
- throw error;
994
- }
995
- }
996
- /**
997
- * `POST /browser/visible` (plan Phase 1 item 4): re-reads the rendered
998
- * result for the engine-observed entity on the session's engine page
999
- * and issues engine-observed visible-result records (row readback for
1000
- * mutations, form readback for reads).
1001
- */
1002
- async function handleBrowserVisible(state, res, body) {
1003
- if (!isPlainObject(body)) {
1004
- throw new HttpError(400, 'browser visible body must be an object');
1005
- }
1006
- const { testId, entityId, operation } = body;
1007
- if (typeof testId !== 'string' || testId.length === 0) {
1008
- throw new HttpError(400, 'browser visible requires a non-empty testId');
1009
- }
1010
- if (typeof entityId !== 'string' || entityId.length === 0) {
1011
- throw new HttpError(400, 'browser visible requires a non-empty entityId');
1012
- }
1013
- if (operation !== 'create' && operation !== 'read' && operation !== 'update' && operation !== 'delete') {
1014
- throw new HttpError(400, "browser visible operation must be one of 'create' | 'read' | 'update' | 'delete'");
1015
- }
1016
- const session = requireOpenSession(state, body);
1017
- const boundTestId = requireSessionTestId(session, testId);
1018
- const { claimIds } = requireBrowserClaims(body);
1019
- const { surface } = requireEngineSurface(session);
1020
- // The driven origin comes from trusted configuration on EVERY call —
1021
- // never from stored suite input (there is none anymore).
1022
- const appBaseUrl = requireTrustedUiBase(state);
1023
- try {
1024
- const page = await state.engineBrowser.pageFor(session.sessionId);
1025
- const fields = await readEngineVisible(page, appBaseUrl, surface, operation, entityId);
1026
- session.activity += 1;
1027
- const issued = claimIds.map((claimId) => issueRecord(state, claimId, 'ui.visible-result', boundTestId, { entityId, fields, sessionId: session.sessionId }, 'engine-observed'));
1028
- const response = {
1029
- entityId,
1030
- fields,
1031
- recordIds: issued.map((record) => record.recordId),
1032
- };
1033
- sendJson(res, 200, response);
1034
- }
1035
- catch (error) {
1036
- if (error instanceof EngineBrowserError) {
1037
- throw new HttpError(409, `engine browser visible read failed: ${error.message}`);
1038
- }
1039
- throw error;
1040
- }
1041
- }
1042
- /** Formats the bind host into a URL host (bracketing IPv6 literals). */
1043
- function formatHost(host) {
1044
- return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
1045
- }
1046
- /** Routes one request through auth + body parse + dispatch. */
1047
- async function handleRequest(state, req, res) {
1048
- try {
1049
- const token = req.headers[RUN_HEADER];
1050
- if (typeof token !== 'string' || !timingSafeEqual(token, state.options.token)) {
1051
- sendJson(res, 401, { error: 'unauthorized: expected x-gateforge-run with the run token' });
1052
- return;
1053
- }
1054
- const url = new URL(req.url ?? '/', 'http://witness');
1055
- const path = url.pathname;
1056
- if (req.method === 'GET' && path === '/health') {
1057
- sendJson(res, 200, {
1058
- ok: true,
1059
- runId: state.options.runId,
1060
- attestationScope: 'loopback+env-fingerprint',
1061
- adapterCount: state.adapters.size,
1062
- recordCount: state.ledger.size,
1063
- });
1064
- return;
1065
- }
1066
- if (req.method === 'GET' && path === '/records') {
1067
- const records = [...state.ledger.values()].sort((a, b) => compareStrings(a.recordId, b.recordId));
1068
- sendJson(res, 200, { records });
1069
- return;
1070
- }
1071
- if (req.method === 'GET' && path === '/ledger-attestation') {
1072
- handleLedgerAttestation(state, res, req.headers[VERIFIER_HEADER]);
1073
- return;
1074
- }
1075
- if (req.method === 'POST' && path === '/run-context') {
1076
- await handleRunContext(state, res, req.headers[VERIFIER_HEADER], await readBody(req));
1077
- return;
1078
- }
1079
- if (req.method === 'GET' && path === '/classifications') {
1080
- const resources = {};
1081
- for (const key of Object.keys(state.classifications).sort(compareStrings)) {
1082
- resources[key] = toClassificationView(state.classifications[key]);
1083
- }
1084
- sendJson(res, 200, { resources });
1085
- return;
1086
- }
1087
- if (req.method === 'POST' && path === '/records') {
1088
- await handleRecords(state, res, (await readBody(req)));
1089
- return;
1090
- }
1091
- if (req.method === 'POST' && path === '/runs/expected-set') {
1092
- await handleExpectedSet(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1093
- return;
1094
- }
1095
- if (req.method === 'POST' && path === '/runs/behavior-catalog') {
1096
- await handleBehaviorCatalog(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1097
- return;
1098
- }
1099
- if (req.method === 'POST' && path === '/behavior/execute') {
1100
- await handleBehaviorExecute(state, res, (await readBody(req)));
1101
- return;
1102
- }
1103
- if (req.method === 'POST' && path === '/behavior/principal') {
1104
- await handleBehaviorPrincipal(state, res, (await readBody(req)));
1105
- return;
1106
- }
1107
- if (req.method === 'POST' && path === '/runs/server-e2e-declarations') {
1108
- await handleServerE2eDeclarations(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1109
- return;
1110
- }
1111
- if (req.method === 'POST' && path === '/runs/observe-declarations') {
1112
- await handleObserveDeclarations(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1113
- return;
1114
- }
1115
- if (req.method === 'POST' && path === '/observe/finalize') {
1116
- await handleObserveFinalize(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1117
- return;
1118
- }
1119
- if (req.method === 'GET' && path === '/runs/execution-trace') {
1120
- requireSupervisor(state, req.headers[VERIFIER_HEADER]);
1121
- sendJson(res, 200, executionTraceOf(state));
1122
- return;
1123
- }
1124
- if (req.method === 'POST' && path === '/sessions/open') {
1125
- requireSupervisor(state, req.headers[VERIFIER_HEADER]);
1126
- await handleSessionOpen(state, res, (await readBody(req)));
1127
- return;
1128
- }
1129
- if (req.method === 'POST' && path === '/sessions/close') {
1130
- requireSupervisor(state, req.headers[VERIFIER_HEADER]);
1131
- await handleSessionClose(state, res, (await readBody(req)));
1132
- return;
1133
- }
1134
- if (req.method === 'POST' && path === '/sessions/resolve') {
1135
- await handleSessionResolve(state, res, (await readBody(req)));
1136
- return;
1137
- }
1138
- if (req.method === 'POST' && path === '/sessions/intervals/open') {
1139
- await handleIntervalOpen(state, res, (await readBody(req)));
1140
- return;
1141
- }
1142
- if (req.method === 'POST' && path === '/sessions/intervals/close') {
1143
- await handleIntervalClose(state, res, (await readBody(req)));
1144
- return;
1145
- }
1146
- if (req.method === 'POST' && path === '/witness/pre-observation') {
1147
- await handlePreObservation(state, res, (await readBody(req)));
1148
- return;
1149
- }
1150
- if (req.method === 'POST' && path === '/witness/persistence') {
1151
- await handlePersistence(state, res, (await readBody(req)));
1152
- return;
1153
- }
1154
- if (req.method === 'POST' && path === '/witness/server-persistence') {
1155
- await handleServerPersistence(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1156
- return;
1157
- }
1158
- if (req.method === 'POST' && path === '/witness/http-observation') {
1159
- await handleHttpObservation(state, res, (await readBody(req)));
1160
- return;
1161
- }
1162
- if (req.method === 'POST' && path === '/browser/surface') {
1163
- await handleBrowserSurface(state, res, (await readBody(req)));
1164
- return;
1165
- }
1166
- if (req.method === 'POST' && path === '/browser/action') {
1167
- await handleBrowserAction(state, res, (await readBody(req)));
1168
- return;
1169
- }
1170
- if (req.method === 'POST' && path === '/browser/visible') {
1171
- await handleBrowserVisible(state, res, (await readBody(req)));
1172
- return;
1173
- }
1174
- sendJson(res, 404, { error: `no witness endpoint at ${req.method} ${path}` });
1175
- }
1176
- catch (error) {
1177
- if (error instanceof HttpError) {
1178
- const detail = error.detail;
1179
- sendJson(res, error.status, detail === null ? { error: error.message } : { error: error.message, detail });
1180
- return;
1181
- }
1182
- if (error instanceof AttestationError) {
1183
- sendJson(res, 409, { error: 'attestation blocked', detail: error.message });
1184
- return;
1185
- }
1186
- sendJson(res, 500, { error: `witness internal error: ${error instanceof Error ? error.message : String(error)}` });
1187
- }
1188
- }
1189
- /** Constant-time token comparison. */
1190
- function timingSafeEqual(a, b) {
1191
- if (a.length !== b.length)
1192
- return false;
1193
- let diff = 0;
1194
- for (let i = 0; i < a.length; i++) {
1195
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1196
- }
1197
- return diff === 0;
1198
- }
1199
- /**
1200
- * Enforces the SUPERVISOR capability (enforcement-review fix 3): the
1201
- * verifier key — the secret the tested suite never receives. The suite's
1202
- * run token authorizes evidence submission and session resolution, never
1203
- * session lifecycle, expected-set registration, traces, or attestation.
1204
- * A witness without a configured verifier key has NO supervisor
1205
- * capability at all: every supervisor endpoint answers 403 (fail closed)
1206
- * rather than degrading to run-token authority.
1207
- *
1208
- * Args:
1209
- * state: running witness state.
1210
- * verifier: the `x-gateforge-verifier` header value.
1211
- *
1212
- * Throws:
1213
- * HttpError: 403 when the witness holds no verifier key (typed
1214
- * 'supervisor authorization required') or 401 on a key mismatch.
1215
- */
1216
- function requireSupervisor(state, verifier) {
1217
- const verifierKey = state.options.verifierKey;
1218
- if (verifierKey === null || verifierKey === undefined) {
1219
- throw new HttpError(403, 'supervisor authorization required: this witness runs without a verifier key, so the ' +
1220
- 'supervisor surface (expected set, session open/close, execution trace) is unavailable — ' +
1221
- 'start the witness with GATEFORGE_WITNESS_VERIFIER_KEY from the orchestrating CLI');
1222
- }
1223
- if (typeof verifier !== 'string' || !timingSafeEqual(verifier, verifierKey)) {
1224
- throw new HttpError(401, 'unauthorized: supervisor endpoints require x-gateforge-verifier with the verifier key ' +
1225
- '(the run token never authorizes session lifecycle)');
1226
- }
1227
- }
1228
- /** Sends a GF-canonical JSON response. */
1229
- function sendJson(res, status, body) {
1230
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
1231
- res.end(canonicalOf(body));
1232
- }
1233
- /**
1234
- * `POST /runs/expected-set` (enforcement-review fix 2a) — SUPERVISOR
1235
- * ONLY: registers the expected test set BEFORE the run, bound to this
1236
- * run in witness memory. Once registered, `/sessions/open` accepts only
1237
- * tests in this set (an invented testId is refused), and the execution
1238
- * trace groups sessions by these registered identities. Identical
1239
- * re-registration is idempotent (200); any change is 409; registration
1240
- * after any session was opened is 409 (the expected set is a PRE-run
1241
- * fact). The response carries the domain-separated enumeration digest
1242
- * the sealed execution result binds.
1243
- *
1244
- * Args:
1245
- * state: running witness state.
1246
- * res: response to answer.
1247
- * verifier: the `x-gateforge-verifier` header value.
1248
- * body: the parsed request body ({tests: [...]}, identity-shaped).
1249
- */
1250
- async function handleExpectedSet(state, res, verifier, body) {
1251
- requireSupervisor(state, verifier);
1252
- if (!isPlainObject(body) || !Array.isArray(body['tests'])) {
1253
- throw new HttpError(400, 'expected-set body must be {tests: [...]}');
1254
- }
1255
- const tests = [];
1256
- for (const entry of body['tests']) {
1257
- if (typeof entry !== 'object' || entry === null) {
1258
- throw new HttpError(400, 'expected-set tests must be objects');
1259
- }
1260
- const row = entry;
1261
- const testId = row['testId'] === undefined || row['testId'] === null ? null : row['testId'];
1262
- const project = row['project'] === undefined || row['project'] === null ? null : row['project'];
1263
- if ((testId !== null && typeof testId !== 'string') ||
1264
- (project !== null && typeof project !== 'string') ||
1265
- typeof row['file'] !== 'string' ||
1266
- row['file'].length === 0 ||
1267
- !Array.isArray(row['titlePath']) ||
1268
- row['titlePath'].length === 0 ||
1269
- !row['titlePath'].every((part) => typeof part === 'string' && part.length > 0)) {
1270
- throw new HttpError(400, 'expected-set tests require file (non-empty string), titlePath (non-empty string array), ' +
1271
- 'and optional testId/project strings');
1272
- }
1273
- tests.push({
1274
- testId: testId,
1275
- project: project,
1276
- file: row['file'],
1277
- titlePath: row['titlePath'],
1278
- });
1279
- }
1280
- const digest = enumerationDigestOf(tests);
1281
- if (state.enumerationDigest !== null) {
1282
- if (state.enumerationDigest === digest) {
1283
- sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
1284
- return;
1285
- }
1286
- sendJson(res, 409, {
1287
- error: 'an expected set is already bound to this run and differs; the expected set is a PRE-run ' +
1288
- 'fact and is never relabeled — start a fresh witness for a new invocation',
1289
- });
1290
- return;
1291
- }
1292
- if (state.sessions.size > 0) {
1293
- sendJson(res, 409, {
1294
- error: 'sessions were already opened on this witness; the expected set must be registered ' +
1295
- 'BEFORE the run — start a fresh witness for a new invocation',
1296
- });
1297
- return;
1298
- }
1299
- state.expectedTests.clear();
1300
- for (const test of tests) {
1301
- state.expectedTests.set(expectedKey(test.project, test.file, test.titlePath), test);
1302
- }
1303
- state.enumerationDigest = digest;
1304
- sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
1305
- }
1306
- /**
1307
- * Binds the compiled behavior catalog plus allowed case/test assignments
1308
- * (plan 2026-09-19 §4.7, Phase 4): supervisor-only, one-time, before any
1309
- * session opens. The worker can never register a catalog or assign
1310
- * itself cases — `/behavior/execute` resolves everything from this
1311
- * binding.
1312
- */
1313
- async function handleBehaviorCatalog(state, res, verifier, body) {
1314
- requireSupervisor(state, verifier);
1315
- if (!isPlainObject(body) || !('catalog' in body) || !isPlainObject(body['assignments'])) {
1316
- throw new HttpError(400, 'behavior-catalog body must be {catalog, assignments: {testId: [caseId, ...]}, routes: [...], authorityProfileDigest}');
1317
- }
1318
- const parsed = BehaviorCatalogSchema.safeParse(body['catalog']);
1319
- if (!parsed.success) {
1320
- const issue = parsed.error.issues[0];
1321
- const path = issue === undefined ? '' : ` at '${issue.path.map(String).join('.')}':`;
1322
- throw new HttpError(400, `behavior catalog is invalid${path} ${issue?.message ?? 'unknown schema error'}`);
1323
- }
1324
- const catalog = parsed.data;
1325
- const rawRoutes = body['routes'];
1326
- if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) {
1327
- throw new HttpError(400, 'behavior-catalog routes must be the non-empty complete route inventory — principal attribution needs every applicable route (no any-endpoint fallback)');
1328
- }
1329
- const routes = [];
1330
- for (let index = 0; index < rawRoutes.length; index += 1) {
1331
- const row = rawRoutes[index];
1332
- if (typeof row !== 'object' ||
1333
- row === null ||
1334
- typeof row['resourceId'] !== 'string' ||
1335
- row['resourceId'].length === 0 ||
1336
- typeof row['method'] !== 'string' ||
1337
- row['method'].length === 0 ||
1338
- typeof row['canonicalPath'] !== 'string' ||
1339
- row['canonicalPath'].length === 0) {
1340
- throw new HttpError(400, `behavior-catalog routes[${String(index)}] must be {resourceId, method, canonicalPath}`);
1341
- }
1342
- routes.push({
1343
- resourceId: row['resourceId'],
1344
- method: row['method'].toUpperCase(),
1345
- canonicalPath: row['canonicalPath'],
1346
- });
1347
- }
1348
- routes.sort((a, b) => (a.resourceId < b.resourceId ? -1 : a.resourceId > b.resourceId ? 1 : 0));
1349
- const authorityProfileDigest = body['authorityProfileDigest'];
1350
- if (typeof authorityProfileDigest !== 'string' || !/^[0-9a-f]{64}$/.test(authorityProfileDigest)) {
1351
- throw new HttpError(400, 'behavior-catalog authorityProfileDigest must be 64-char lowercase hex');
1352
- }
1353
- // Approved surface descriptors (Phase 6): optional map of surface key
1354
- // to descriptor. Each descriptor is structurally validated NOW —
1355
- // worker-supplied surfaces are never resolved at drive time.
1356
- const surfaces = new Map();
1357
- const rawSurfaces = body['surfaces'];
1358
- if (rawSurfaces !== undefined) {
1359
- if (!isPlainObject(rawSurfaces)) {
1360
- throw new HttpError(400, 'behavior-catalog surfaces must be a map of surface key to descriptor');
1361
- }
1362
- for (const [surfaceKey, descriptor] of Object.entries(rawSurfaces)) {
1363
- if (surfaceKey.length === 0) {
1364
- throw new HttpError(400, 'behavior-catalog surface keys must be non-empty');
1365
- }
1366
- try {
1367
- surfaces.set(surfaceKey, validateSurface(descriptor));
1368
- }
1369
- catch (error) {
1370
- throw new HttpError(400, `behavior-catalog surface '${surfaceKey}' is invalid: ${error.message}`);
1371
- }
1372
- }
1373
- }
1374
- const assignments = new Map();
1375
- for (const [testId, ids] of Object.entries(body['assignments'])) {
1376
- if (testId.length === 0 || !Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === 'string')) {
1377
- throw new HttpError(400, `behavior-catalog assignment for test '${testId}' must be a non-empty array of case ids`);
1378
- }
1379
- const resolved = new Set();
1380
- for (const id of ids) {
1381
- const found = catalog.cases.find((item) => item.caseId === id);
1382
- if (found === undefined) {
1383
- throw new HttpError(400, `behavior-catalog assignment for test '${testId}' names unknown case '${id}' — cases resolve by canonical case id only`);
1384
- }
1385
- resolved.add(found.caseId);
1386
- }
1387
- assignments.set(testId, resolved);
1388
- }
1389
- if (state.behaviorCatalog !== null) {
1390
- if (state.behaviorCatalog.catalogDigest === catalog.catalogDigest) {
1391
- const response = {
1392
- bound: true,
1393
- caseCount: catalog.cases.length,
1394
- assignmentCount: assignments.size,
1395
- routeCount: routes.length,
1396
- };
1397
- sendJson(res, 200, response);
1398
- return;
1399
- }
1400
- sendJson(res, 409, {
1401
- error: 'a behavior catalog is already bound to this run and differs; the catalog is a PRE-run ' +
1402
- 'fact and is never relabeled — start a fresh witness for a new invocation',
1403
- });
1404
- return;
1405
- }
1406
- if (state.sessions.size > 0) {
1407
- sendJson(res, 409, {
1408
- error: 'sessions were already opened on this witness; the behavior catalog must be registered ' +
1409
- 'BEFORE the run — start a fresh witness for a new invocation',
1410
- });
1411
- return;
1412
- }
1413
- state.behaviorCatalog = { catalog, catalogDigest: catalog.catalogDigest, assignments, routes, authorityProfileDigest, surfaces };
1414
- const response = {
1415
- bound: true,
1416
- caseCount: catalog.cases.length,
1417
- assignmentCount: assignments.size,
1418
- routeCount: routes.length,
1419
- };
1420
- sendJson(res, 200, response);
1421
- }
1422
- /**
1423
- * Executes one required behavior case through the witness-owned lifecycle
1424
- * (plan 2026-09-19 §4.6, Phase 4): the worker names an allowed caseId and
1425
- * nothing else. Actor credentials, expectations, before-state, and origin
1426
- * resolve from the supervisor-bound catalog and the trusted fixture
1427
- * provider — input/actor overrides are request errors, never proof.
1428
- *
1429
- * Phase 4 executes fixture preparation plus the authoritative before
1430
- * snapshot; the principal operation driver lands in Phase 5, so a
1431
- * successful call leaves the execution at `before-snapshot-complete`
1432
- * with a redacted reference (no credentials, no subjects).
1433
- */
1434
- async function handleBehaviorExecute(state, res, body) {
1435
- if (!isPlainObject(body)) {
1436
- throw new HttpError(400, 'behavior/execute body must be {sessionId, sessionToken, caseId}');
1437
- }
1438
- const keys = Object.keys(body).sort();
1439
- if (keys.length !== 3 || keys[0] !== 'caseId' || keys[1] !== 'sessionId' || keys[2] !== 'sessionToken') {
1440
- throw new HttpError(400, 'behavior/execute accepts exactly {sessionId, sessionToken, caseId} — unknown keys are errors; ' +
1441
- 'a worker can name an allowed case but cannot post actor credentials, expectations, or origin');
1442
- }
1443
- const caseId = body['caseId'];
1444
- if (typeof caseId !== 'string' || caseId.length === 0) {
1445
- throw new HttpError(400, 'behavior/execute requires a non-empty string caseId');
1446
- }
1447
- const binding = state.behaviorCatalog;
1448
- if (binding === null) {
1449
- throw new HttpError(409, 'no behavior catalog is bound to this run — register POST /runs/behavior-catalog before the run');
1450
- }
1451
- const session = requireOpenSession(state, body);
1452
- const compiled = binding.catalog.cases.find((item) => item.caseId === caseId);
1453
- if (compiled === undefined) {
1454
- throw new HttpError(400, `behavior/execute names unknown case '${caseId}' — cases resolve by canonical case id only`);
1455
- }
1456
- const allowed = binding.assignments.get(session.testId);
1457
- if (allowed === undefined || !allowed.has(compiled.caseId)) {
1458
- throw new HttpError(403, `case '${compiled.definition.id}' is not assigned to test '${session.testId}' — a test executes only its own allowed cases`);
1459
- }
1460
- if (state.caseExecutions.has(compiled.caseId)) {
1461
- throw new HttpError(409, `case '${compiled.definition.id}' was already executed in this run — one execution per required case per run`);
1462
- }
1463
- const provider = state.options.fixtureProvider ?? null;
1464
- if (provider === null) {
1465
- throw new HttpError(409, 'no trusted fixture provider is configured — strong behavior cases block (suite-supplied fixtures are never a fallback)');
1466
- }
1467
- let lease;
1468
- try {
1469
- lease = await provider.prepare({
1470
- recipe: compiled.definition.fixture,
1471
- runId: state.options.runId,
1472
- caseId: compiled.caseId,
1473
- });
1474
- }
1475
- catch (error) {
1476
- throw new HttpError(500, `fixture preparation failed: ${error instanceof Error ? error.message : String(error)}`);
1477
- }
1478
- if (lease === null ||
1479
- typeof lease !== 'object' ||
1480
- typeof lease.namespace !== 'string' ||
1481
- lease.namespace.length === 0) {
1482
- try {
1483
- await provider.release(lease?.leaseId ?? 'unknown');
1484
- }
1485
- catch {
1486
- // Release is best-effort; the preparation failure below is the verdict.
1487
- }
1488
- throw new HttpError(500, 'fixture preparation returned a malformed lease (fail closed)');
1489
- }
1490
- const executionId = randomUUID();
1491
- const execution = {
1492
- executionId,
1493
- caseId: compiled.caseId,
1494
- testId: session.testId,
1495
- sessionId: session.sessionId,
1496
- state: 'fixture-prepared',
1497
- lease: lease,
1498
- beforeSnapshots: [],
1499
- beforeCheckpoints: {},
1500
- detail: null,
1501
- };
1502
- state.caseExecutions.set(compiled.caseId, execution);
1503
- const failExecution = (detail) => {
1504
- execution.state = 'failed';
1505
- execution.detail = detail;
1506
- // Namespace cleanup runs on errors without changing the verdict.
1507
- // Fire-and-forget with a floor: the thrown error carries the failure.
1508
- void Promise.resolve(provider.release(execution.lease.leaseId)).catch(() => { });
1509
- return new HttpError(409, detail);
1510
- };
1511
- // Authoritative before snapshots over every declared effect scope. Any
1512
- // incomplete collection fails the execution with a diagnostic fact —
1513
- // never a satisfying observation.
1514
- const checkpoints = {};
1515
- for (const effect of compiled.effects) {
1516
- const adapter = state.adapters.get(effect.adapter);
1517
- if (adapter === undefined) {
1518
- throw failExecution(`observation incomplete: no reviewed adapter '${effect.adapter}' for scope '${effect.scope}' (OBSERVATION_SCOPE_INCOMPLETE)`);
1519
- }
1520
- if (typeof adapter.snapshotScope !== 'function') {
1521
- throw failExecution(`observation incomplete: adapter '${effect.adapter}' cannot observe scope '${effect.scope}' — snapshotScope is unavailable (OBSERVATION_SCOPE_INCOMPLETE)`);
1522
- }
1523
- const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl ?? '';
1524
- const ctx = makeAdapterContext(baseUrl, effect.resourceId, () => {
1525
- throw new Error('snapshot observations must not use the candidate GET transport');
1526
- }, state.options.adapterReadAuthorization
1527
- ? { authorization: state.options.adapterReadAuthorization }
1528
- : undefined);
1529
- let snapshot;
1530
- try {
1531
- snapshot = await adapter.snapshotScope(ctx, { scope: effect.scope, fixtureNamespace: execution.lease.namespace });
1532
- }
1533
- catch (error) {
1534
- throw failExecution(`observation incomplete: scope '${effect.scope}' collection failed: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
1535
- }
1536
- const validated = validateScopeSnapshot(snapshot, {
1537
- scope: effect.scope,
1538
- fixtureNamespace: execution.lease.namespace,
1539
- identityFields: effect.identityFields,
1540
- fields: effect.fields,
1541
- });
1542
- if (!validated.ok) {
1543
- throw failExecution(`observation incomplete: ${validated.detail} (OBSERVATION_SCOPE_INCOMPLETE)`);
1544
- }
1545
- checkpoints[effect.scope] = validated.snapshot.checkpoint;
1546
- execution.beforeSnapshots.push(snapshot);
1547
- }
1548
- execution.beforeCheckpoints = checkpoints;
1549
- execution.state = 'before-snapshot-complete';
1550
- const firstCheckpoint = Object.values(checkpoints).sort()[0] ?? null;
1551
- const response = {
1552
- caseId: compiled.caseId,
1553
- executionId,
1554
- namespace: execution.lease.namespace,
1555
- beforeCheckpoint: firstCheckpoint,
1556
- state: execution.state,
1557
- };
1558
- sendJson(res, 200, response);
1559
- }
1560
- /**
1561
- * Drives one surface action through the engine-owned browser (plan
1562
- * 2026-09-19 Phase 6): resolves the surface descriptor from the
1563
- * supervisor-bound bundle (never a worker object), resolves
1564
- * subject/fields from the trusted lease, drives with the existing
1565
- * engine browser drivers, and reads the rendered result back itself.
1566
- * Error banners, navigation, and state checks are engine observations —
1567
- * never inferred from test source.
1568
- */
1569
- async function driveSurfacePrincipal(state, binding, execution, compiled, action, failExecution, session) {
1570
- const descriptor = binding.surfaces.get(action.surface);
1571
- if (descriptor === undefined) {
1572
- throw failExecution(`surface '${action.surface}' has no approved descriptor in the bound bundle — strong cases resolve surfaces from the approved bundle, not worker objects (BEHAVIOR_BINDING_MISMATCH)`);
1573
- }
1574
- if (action.files !== undefined && Object.keys(action.files).length > 0) {
1575
- throw failExecution('surface file inputs need fixture file materialization, unsupported in this profile — declare the upload as an explicit engine-http multipart case or omit files (OBSERVATION_SCOPE_INCOMPLETE)');
1576
- }
1577
- const resolveField = (raw, what) => {
1578
- if (raw.from === 'literal') {
1579
- if (typeof raw.value !== 'string') {
1580
- throw failExecution(`surface ${what} literal must be a string (BEHAVIOR_BINDING_MISMATCH)`);
1581
- }
1582
- return raw.value;
1583
- }
1584
- if (raw.from === 'fixture' && typeof raw.key === 'string') {
1585
- const segments = raw.key.split('.');
1586
- let current = execution.lease.subjects;
1587
- for (const segment of segments) {
1588
- if (typeof current !== 'object' || current === null || Array.isArray(current)) {
1589
- throw failExecution(`surface ${what} fixture key '${raw.key}' does not resolve (BEHAVIOR_BINDING_MISMATCH)`);
1590
- }
1591
- if (!Object.prototype.hasOwnProperty.call(current, segment)) {
1592
- throw failExecution(`surface ${what} fixture key '${raw.key}' does not resolve (BEHAVIOR_BINDING_MISMATCH)`);
1593
- }
1594
- current = current[segment];
1595
- }
1596
- if (typeof current !== 'string') {
1597
- throw failExecution(`surface ${what} fixture key '${raw.key}' is not a string (BEHAVIOR_BINDING_MISMATCH)`);
1598
- }
1599
- return current;
1600
- }
1601
- throw failExecution(`surface ${what} uses an unsupported value source (BEHAVIOR_BINDING_MISMATCH)`);
1602
- };
1603
- let subject;
1604
- if (action.subject !== undefined) {
1605
- subject = resolveField(action.subject, 'subject');
1606
- }
1607
- const fields = {};
1608
- for (const [name, raw] of Object.entries(action.fields)) {
1609
- fields[name] = resolveField(raw, `field '${name}'`);
1610
- }
1611
- let appBase;
1612
- try {
1613
- appBase = requireTrustedUiBase(state);
1614
- }
1615
- catch (error) {
1616
- throw failExecution(error instanceof Error ? error.message : String(error));
1617
- }
1618
- let page;
1619
- try {
1620
- page = await state.engineBrowser.pageFor(session.sessionId);
1621
- }
1622
- catch (error) {
1623
- throw failExecution(`engine browser unavailable: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
1624
- }
1625
- let observation;
1626
- try {
1627
- observation = await driveEngineAction(page, appBase, descriptor, action.operation, {
1628
- ...(Object.keys(fields).length > 0 ? { fields } : {}),
1629
- ...(subject !== undefined ? { entityId: subject } : {}),
1630
- });
1631
- }
1632
- catch (error) {
1633
- throw failExecution(`engine surface ${action.operation} failed: ${error instanceof Error ? error.message : String(error)} (BEHAVIOR_EFFECT_MISMATCH)`);
1634
- }
1635
- let visible;
1636
- try {
1637
- visible = await readEngineVisible(page, appBase, descriptor, action.operation, observation.entityId);
1638
- }
1639
- catch (error) {
1640
- throw failExecution(`engine visible read failed: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
1641
- }
1642
- let url;
1643
- try {
1644
- url = page.url();
1645
- }
1646
- catch (error) {
1647
- throw failExecution(`engine page url unreadable: ${error instanceof Error ? error.message : String(error)}`);
1648
- }
1649
- return {
1650
- operationId: randomUUID(),
1651
- browserObservation: { url, entityId: observation.entityId, visibleFields: { ...visible } },
1652
- submittedValues: {
1653
- surface: action.surface,
1654
- operation: action.operation,
1655
- ...(subject !== undefined ? { subject } : {}),
1656
- fields: { ...fields },
1657
- },
1658
- };
1659
- }
1660
- /**
1661
- * Drives the principal operation of a prepared case (plan 2026-09-19
1662
- * §4.6, Phase 5): resolves the execution, runs the witness-owned
1663
- * engine-http request driver for `request` actions, attributes the
1664
- * captured attempt against the bound route inventory, resolves the
1665
- * completion barrier, observes after-state, and issues the sealed
1666
- * `behavior.case` record(s) — one per compiled obligation. Grading
1667
- * happens core-side, never here.
1668
- *
1669
- * `surface` actions need the Phase 6 browser driver; `deliver` and
1670
- * `sequence` need Phase 8 — all block with an explicit cause.
1671
- */
1672
- async function handleBehaviorPrincipal(state, res, body) {
1673
- if (!isPlainObject(body)) {
1674
- throw new HttpError(400, 'behavior/principal body must be {sessionId, sessionToken, executionId}');
1675
- }
1676
- const keys = Object.keys(body).sort();
1677
- if (keys.length !== 3 || keys[0] !== 'executionId' || keys[1] !== 'sessionId' || keys[2] !== 'sessionToken') {
1678
- throw new HttpError(400, 'behavior/principal accepts exactly {sessionId, sessionToken, executionId}');
1679
- }
1680
- const executionId = body['executionId'];
1681
- if (typeof executionId !== 'string' || executionId.length === 0) {
1682
- throw new HttpError(400, 'behavior/principal requires a non-empty string executionId');
1683
- }
1684
- const binding = state.behaviorCatalog;
1685
- if (binding === null) {
1686
- throw new HttpError(409, 'no behavior catalog is bound to this run');
1687
- }
1688
- const session = requireOpenSession(state, body);
1689
- const execution = [...state.caseExecutions.values()].find((item) => item.executionId === executionId);
1690
- if (execution === undefined) {
1691
- throw new HttpError(400, `behavior/principal names unknown execution '${executionId}'`);
1692
- }
1693
- if (execution.sessionId !== session.sessionId) {
1694
- throw new HttpError(403, 'behavior/principal session does not own this execution — executions belong to the session that prepared them');
1695
- }
1696
- if (execution.state === 'sealed') {
1697
- throw new HttpError(409, 'behavior/principal execution is already sealed — one execution per required case per run');
1698
- }
1699
- if (execution.state === 'failed') {
1700
- throw new HttpError(409, `behavior/principal execution failed: ${execution.detail ?? 'unknown failure'}`);
1701
- }
1702
- if (execution.state !== 'before-snapshot-complete') {
1703
- throw new HttpError(409, `behavior/principal execution is in state '${execution.state}', not ready for the principal`);
1704
- }
1705
- const compiled = binding.catalog.cases.find((item) => item.caseId === execution.caseId);
1706
- if (compiled === undefined) {
1707
- throw new HttpError(409, 'behavior/principal case vanished from the bound catalog (fail closed)');
1708
- }
1709
- const failExecution = (detail) => {
1710
- execution.state = 'failed';
1711
- execution.detail = detail;
1712
- const provider = state.options.fixtureProvider ?? null;
1713
- if (provider !== null) {
1714
- void Promise.resolve(provider.release(execution.lease.leaseId)).catch(() => { });
1715
- }
1716
- return new HttpError(409, detail);
1717
- };
1718
- const action = compiled.definition.action;
1719
- const actorProfile = compiled.definition.actor;
1720
- const provider = state.options.fixtureProvider ?? null;
1721
- // The sealed principal evidence: exactly one of the two drivers fills
1722
- // its half. Request cases seal attempts + request observations;
1723
- // surface cases seal the browser observation.
1724
- let attempts = [];
1725
- let requestObservations = [];
1726
- let browserObservation = undefined;
1727
- let submittedValues;
1728
- let operationId;
1729
- execution.state = 'principal-executing';
1730
- if (action.kind === 'request') {
1731
- const requestAction = action;
1732
- if (compiled.definition.channel !== 'engine-http') {
1733
- throw failExecution(`case '${compiled.definition.id}' requires the '${compiled.definition.channel}' channel — the Phase 8 task driver produces that evidence`);
1734
- }
1735
- if (provider === null || typeof provider.resolveCredential !== 'function') {
1736
- throw failExecution('no trusted credential resolver is configured — the principal cannot authenticate engine-side');
1737
- }
1738
- const origin = state.options.targetBaseUrl ?? null;
1739
- if (origin === null || origin === '') {
1740
- throw failExecution('no approved subject origin is configured for the principal driver');
1741
- }
1742
- let driven;
1743
- try {
1744
- driven = await driveBehaviorRequest({
1745
- action: {
1746
- kind: 'request',
1747
- method: requestAction.method,
1748
- pathTemplate: requestAction.pathTemplate,
1749
- path: requestAction.path,
1750
- query: requestAction.query,
1751
- body: requestAction.body,
1752
- credentialVariant: requestAction.credentialVariant,
1753
- ...(requestAction.signatureProfile !== undefined ? { signatureProfile: requestAction.signatureProfile } : {}),
1754
- },
1755
- lease: execution.lease,
1756
- actorProfile,
1757
- origin,
1758
- resolveCredential: (credentialRef) => provider.resolveCredential(credentialRef),
1759
- timeoutMs: state.options.requestTimeoutMs,
1760
- });
1761
- }
1762
- catch (error) {
1763
- if (error instanceof BehaviorDriverError) {
1764
- throw failExecution(`principal driver: ${error.message} (OBSERVATION_SCOPE_INCOMPLETE)`);
1765
- }
1766
- throw failExecution(`principal driver failed: ${error instanceof Error ? error.message : String(error)}`);
1767
- }
1768
- execution.state = 'principal-captured';
1769
- // Attribute the captured attempt against the bound inventory with the
1770
- // compiled endpoint as expected — the grader re-resolves
1771
- // independently; a misdirected principal fails here with a diagnostic
1772
- // instead of sealing evidence for the wrong endpoint.
1773
- const interpreted = interpretObservedPath(driven.path);
1774
- if (!interpreted.ok) {
1775
- throw failExecution(`principal captured a noncanonical path: ${interpreted.reason} (BEHAVIOR_BINDING_MISMATCH)`);
1776
- }
1777
- const expectedEndpoint = compiled.endpointResourceId ?? compiled.resourceId;
1778
- const resolution = resolveHttpRoute(driven.method, interpreted.path, binding.routes, expectedEndpoint);
1779
- if (resolution.status !== 'match') {
1780
- const reason = resolution.status === 'ambiguous'
1781
- ? `ambiguous route attribution for ${driven.method} ${interpreted.path} (BEHAVIOR_BINDING_MISMATCH)`
1782
- : resolution.status === 'mismatch'
1783
- ? `principal reached '${resolution.matched.resourceId}', not the required endpoint '${expectedEndpoint}' (BEHAVIOR_BINDING_MISMATCH)`
1784
- : resolution.status === 'nomatch'
1785
- ? `principal ${driven.method} ${interpreted.path} matches no inventoried route (BEHAVIOR_BINDING_MISMATCH)`
1786
- : resolution.reason;
1787
- throw failExecution(reason);
1788
- }
1789
- operationId = driven.engineRequestId;
1790
- attempts = [
1791
- {
1792
- engineRequestId: driven.engineRequestId,
1793
- method: driven.method,
1794
- path: driven.path,
1795
- endpointResourceId: resolution.matched.resourceId,
1796
- actorRef: actorProfile,
1797
- requestDigest: driven.requestDigest,
1798
- status: driven.status,
1799
- responseDigest: driven.responseDigest,
1800
- },
1801
- ];
1802
- requestObservations = [
1803
- {
1804
- engineRequestId: driven.engineRequestId,
1805
- method: driven.method,
1806
- path: driven.path,
1807
- query: { ...driven.query },
1808
- body: driven.body,
1809
- status: driven.status,
1810
- responseBody: driven.responseBody,
1811
- },
1812
- ];
1813
- submittedValues = { path: driven.path, query: { ...driven.query }, body: driven.body };
1814
- }
1815
- else if (action.kind === 'surface') {
1816
- const surfaceAction = action;
1817
- const surfaceChannel = compiled.definition.channel;
1818
- if (surfaceChannel !== 'engine-browser') {
1819
- throw failExecution(`surface case '${compiled.definition.id}' requires the engine-browser channel — blocked, never satisfied`);
1820
- }
1821
- const driven = await driveSurfacePrincipal(state, binding, execution, compiled, surfaceAction, failExecution, session);
1822
- operationId = driven.operationId;
1823
- browserObservation = driven.browserObservation;
1824
- submittedValues = driven.submittedValues;
1825
- execution.state = 'principal-captured';
1826
- }
1827
- else {
1828
- throw failExecution(`case '${compiled.definition.id}' uses a '${action.kind}' action — the Phase 8 task driver produces that evidence`);
1829
- }
1830
- // Completion barrier: immediate effects resolve at once; barrier
1831
- // effects need a real checkpoint from an adapter observer.
1832
- const barrierEffects = compiled.effects.filter((effect) => effect.completion === 'barrier');
1833
- if (barrierEffects.length > 0) {
1834
- for (const effect of barrierEffects) {
1835
- const adapter = state.adapters.get(effect.adapter);
1836
- if (adapter === undefined || typeof adapter.awaitBarrier !== 'function') {
1837
- throw failExecution(`observation incomplete: no barrier observer for scope '${effect.scope}' (OBSERVATION_SCOPE_INCOMPLETE)`);
1838
- }
1839
- const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl ?? '';
1840
- const ctx = makeAdapterContext(baseUrl, effect.resourceId, () => {
1841
- throw new Error('barrier observations must not use the candidate GET transport');
1842
- }, state.options.adapterReadAuthorization
1843
- ? { authorization: state.options.adapterReadAuthorization }
1844
- : undefined);
1845
- let barrier;
1846
- try {
1847
- barrier = await adapter.awaitBarrier(ctx, {
1848
- scope: effect.scope,
1849
- fixtureNamespace: execution.lease.namespace,
1850
- operationId,
1851
- deadlineMs: state.options.barrierTimeoutMs ?? 30_000,
1852
- });
1853
- }
1854
- catch (error) {
1855
- throw failExecution(`observation incomplete: barrier for scope '${effect.scope}' failed: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
1856
- }
1857
- if (barrier === null || typeof barrier !== 'object' || barrier.complete !== true) {
1858
- throw failExecution(`async effect did not complete before the barrier deadline — premature success is blocked (OBSERVATION_SCOPE_INCOMPLETE)`);
1859
- }
1860
- }
1861
- }
1862
- execution.state = 'barrier-reached';
1863
- // Authoritative after snapshots over every declared effect scope.
1864
- const afterSnapshots = [];
1865
- const afterCheckpoints = {};
1866
- for (const effect of compiled.effects) {
1867
- const adapter = state.adapters.get(effect.adapter);
1868
- if (adapter === undefined || typeof adapter.snapshotScope !== 'function') {
1869
- throw failExecution(`observation incomplete: scope '${effect.scope}' lost its observer (OBSERVATION_SCOPE_INCOMPLETE)`);
1870
- }
1871
- const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl ?? '';
1872
- const ctx = makeAdapterContext(baseUrl, effect.resourceId, () => {
1873
- throw new Error('snapshot observations must not use the candidate GET transport');
1874
- }, state.options.adapterReadAuthorization
1875
- ? { authorization: state.options.adapterReadAuthorization }
1876
- : undefined);
1877
- let snapshot;
1878
- try {
1879
- snapshot = await adapter.snapshotScope(ctx, { scope: effect.scope, fixtureNamespace: execution.lease.namespace });
1880
- }
1881
- catch (error) {
1882
- throw failExecution(`observation incomplete: after-scope '${effect.scope}' collection failed: ${error instanceof Error ? error.message : String(error)} (OBSERVATION_SCOPE_INCOMPLETE)`);
1883
- }
1884
- const validated = validateScopeSnapshot(snapshot, {
1885
- scope: effect.scope,
1886
- fixtureNamespace: execution.lease.namespace,
1887
- identityFields: effect.identityFields,
1888
- fields: effect.fields,
1889
- });
1890
- if (!validated.ok) {
1891
- throw failExecution(`observation incomplete: ${validated.detail} (OBSERVATION_SCOPE_INCOMPLETE)`);
1892
- }
1893
- afterCheckpoints[effect.scope] = validated.snapshot.checkpoint;
1894
- afterSnapshots.push(snapshot);
1895
- }
1896
- execution.state = 'after-snapshot-complete';
1897
- // Seal: strip snapshots to the core strict shape (no witness-local
1898
- // validation extras), then issue one record per compiled obligation.
1899
- const stripSnapshot = (snapshot) => ({
1900
- scope: snapshot.scope,
1901
- fixtureNamespace: snapshot.fixtureNamespace,
1902
- complete: snapshot.complete,
1903
- checkpoint: snapshot.checkpoint,
1904
- entities: snapshot.entities.map((entity) => ({ entityId: entity.entityId, fields: { ...entity.fields } })),
31
+ export function startWitness(options) {
32
+ return startRunnerNeutralWitness({
33
+ ...options,
34
+ engineBrowserLauncher: options.engineBrowserLauncher ?? chromium,
1905
35
  });
1906
- const actorLease = execution.lease.actors[actorProfile];
1907
- const payload = {
1908
- payloadVersion: 1,
1909
- caseId: compiled.caseId,
1910
- caseSpecDigest: compiled.specDigest,
1911
- obligationIds: [...compiled.obligationIds],
1912
- endpointResourceId: compiled.endpointResourceId,
1913
- operationId,
1914
- sessionId: session.sessionId,
1915
- executionId: execution.executionId,
1916
- fixtureNamespace: execution.lease.namespace,
1917
- actor: {
1918
- principalId: typeof actorLease?.principalId === 'string' ? actorLease.principalId : actorProfile,
1919
- tenantId: typeof actorLease?.tenantId === 'string' || actorLease?.tenantId === null ? (actorLease?.tenantId ?? null) : null,
1920
- roles: Array.isArray(actorLease?.roles) ? [...actorLease?.roles] : [],
1921
- },
1922
- actionDigest: behaviorActionDigestOf(compiled.definition.action),
1923
- submittedValues: submittedValues,
1924
- attempts,
1925
- requestObservations,
1926
- ...(browserObservation === undefined ? {} : { browserObservation }),
1927
- fixtureValues: { ...execution.lease.subjects },
1928
- before: execution.beforeSnapshots.map(stripSnapshot),
1929
- after: afterSnapshots.map(stripSnapshot),
1930
- completion: {
1931
- complete: true,
1932
- checkpoint: Object.values(afterCheckpoints).sort().join('+') || Object.values(execution.beforeCheckpoints).sort().join('+'),
1933
- },
1934
- channel: compiled.definition.channel,
1935
- authorityProfileDigest: binding.authorityProfileDigest,
1936
- state: 'sealed',
1937
- };
1938
- const recordIds = [];
1939
- for (const obligationId of compiled.obligationIds) {
1940
- const issued = issueRecord(state, obligationId, BEHAVIOR_CASE_KIND, session.testId, payload, 'engine-observed');
1941
- recordIds.push(issued.recordId);
1942
- }
1943
- recordIds.sort();
1944
- execution.state = 'sealed';
1945
- const response = {
1946
- caseId: compiled.caseId,
1947
- executionId: execution.executionId,
1948
- recordIds,
1949
- state: execution.state,
1950
- };
1951
- sendJson(res, 200, response);
1952
- }
1953
- /**
1954
- * Builds the witness-side execution trace (enforcement-review fix 2b):
1955
- * per registered expected test, every recorded session with its open/
1956
- * seal ticks and outcome. When no expected set is registered, sessions
1957
- * are grouped by their own open identity (the legacy/standalone shape).
1958
- * This record lives ONLY in witness memory — the suite cannot mint,
1959
- * alter, or replay it — and is the authority supervision grades
1960
- * completeness from.
1961
- */
1962
- function executionTraceOf(state) {
1963
- const byKey = new Map();
1964
- for (const session of state.sessions.values()) {
1965
- const key = session.registered !== null
1966
- ? expectedKey(session.registered.project, session.registered.file, session.registered.titlePath)
1967
- : `runtime\u0000${session.sessionId}`;
1968
- let entry = byKey.get(key);
1969
- if (entry === undefined) {
1970
- entry = {
1971
- testId: session.registered?.testId ?? session.testId,
1972
- project: session.registered?.project ?? null,
1973
- file: session.registered?.file ?? '',
1974
- titlePath: session.registered?.titlePath ?? [],
1975
- sessions: [],
1976
- };
1977
- byKey.set(key, entry);
1978
- }
1979
- const traced = {
1980
- sessionId: session.sessionId,
1981
- openedTick: session.openedTick,
1982
- sealedTick: session.sealedTick,
1983
- outcome: session.outcome,
1984
- // Witness-side activity (review recheck fix 2026-09-14): the count
1985
- // of session-bound observations the witness itself made. A sealed
1986
- // 'passed' session with zero activity blocks supervision — the
1987
- // runner-reported outcome alone proves nothing.
1988
- activity: session.activity,
1989
- };
1990
- entry.sessions.push(traced);
1991
- }
1992
- return {
1993
- enumerationDigest: state.enumerationDigest,
1994
- tests: [...byKey.entries()]
1995
- .sort((a, b) => compareStrings(a[0], b[0]))
1996
- .map(([, entry]) => ({ ...entry, sessions: [...entry.sessions].sort((a, b) => a.openedTick - b.openedTick) })),
1997
- };
1998
- }
1999
- /**
2000
- * `POST /sessions/open` (plan Phase 1; enforcement-review fix 3) —
2001
- * SUPERVISOR ONLY: the trusted CLI (which owns the verifier key) registers
2002
- * one started test. The witness issues the session binding — (runId,
2003
- * sessionId, testId, worker) — and, when an observation proxy is active,
2004
- * a per-session browser mount segment. One session may be OPEN per worker
2005
- * at a time (a worker runs one test at a time; overlapping sessions would
2006
- * make interval attribution ambiguous — fail closed instead). Re-opening
2007
- * the identical (worker, testId) pair while it is still open is
2008
- * idempotent (double `testBegin` dispatch safety).
2009
- *
2010
- * When an expected set is registered (fix 2a), the opened test must
2011
- * belong to it — matched by the registered (project, file, titlePath)
2012
- * identity the supervisor carries from the runner's lifecycle spool — so
2013
- * a suite-invented testId can never mint a session.
2014
- *
2015
- * The suite holds the run token but CANNOT mint sessions with it: the
2016
- * run token alone answers 403 typed.
2017
- */
2018
- async function handleSessionOpen(state, res, body) {
2019
- if (!isPlainObject(body)) {
2020
- throw new HttpError(400, 'session open body must be an object');
2021
- }
2022
- const { testId, workerIndex } = body;
2023
- if (typeof testId !== 'string' || testId.length === 0) {
2024
- throw new HttpError(400, 'session open requires a non-empty testId (the runner-assigned test id)');
2025
- }
2026
- if (typeof workerIndex !== 'number' || !Number.isInteger(workerIndex) || workerIndex < 0) {
2027
- throw new HttpError(400, 'session open requires a non-negative integer workerIndex');
2028
- }
2029
- // Expected-set membership (fix 2a): once the supervisor registered the
2030
- // expected tests, sessions exist only for them. Identity comes from the
2031
- // supervisor's spool drain (file + titlePath + project).
2032
- let registered = null;
2033
- if (state.enumerationDigest !== null) {
2034
- const file = typeof body['file'] === 'string' ? body['file'] : null;
2035
- const rawTitlePath = Array.isArray(body['titlePath'])
2036
- ? body['titlePath'].filter((part) => typeof part === 'string')
2037
- : null;
2038
- const project = typeof body['project'] === 'string' ? body['project'] : null;
2039
- registered =
2040
- file !== null && rawTitlePath !== null && rawTitlePath.length > 0
2041
- ? state.expectedTests.get(expectedKey(project, file, rawTitlePath)) ?? null
2042
- : null;
2043
- if (registered === null) {
2044
- throw new HttpError(403, `session open refused: test '${testId}' is not in the registered expected set — ` +
2045
- 'sessions are minted only for the tests the supervisor registered before the run');
2046
- }
2047
- }
2048
- const existingWorkerSession = state.workerSessions.get(workerIndex);
2049
- if (existingWorkerSession !== undefined) {
2050
- const open = state.sessions.get(existingWorkerSession);
2051
- if (open !== undefined && open.testId === testId) {
2052
- // Idempotent re-open of the identical (worker, testId) session.
2053
- sendJson(res, 200, sessionView(state, open));
2054
- return;
2055
- }
2056
- sendJson(res, 409, {
2057
- error: `worker ${String(workerIndex)} already carries an open session for testId ` +
2058
- `'${open === undefined ? existingWorkerSession : open.testId}'; a worker runs one test ` +
2059
- 'at a time — close the session before opening another',
2060
- });
2061
- return;
2062
- }
2063
- // Phase 4 claim injection: the supervisor carries the mapped obligation
2064
- // claims for this test on the session-open path. Claims stay
2065
- // DECLARATIONS (they never satisfy anything by themselves), but the
2066
- // witness normalizes them — obligation-id-shaped, sorted, deduplicated
2067
- // — and refuses malformed values so a typo cannot silently redirect
2068
- // evidence to a nonexistent claim identity.
2069
- const rawClaims = body['claims'];
2070
- let claims = [];
2071
- if (rawClaims !== undefined) {
2072
- if (!Array.isArray(rawClaims)) {
2073
- throw new HttpError(400, 'session open claims, when present, must be an array of obligation ids');
2074
- }
2075
- const seen = new Set();
2076
- for (const claim of rawClaims) {
2077
- if (typeof claim !== 'string' || !OBLIGATION_ID_PATTERN.test(claim)) {
2078
- throw new HttpError(400, `session open claims must be obligation ids '<resourceId>:<contract>' (got '${String(claim)}')`);
2079
- }
2080
- seen.add(claim);
2081
- }
2082
- claims = [...seen].sort(compareStrings);
2083
- }
2084
- const sessionId = randomUUID();
2085
- const session = {
2086
- sessionId,
2087
- token: randomUUID(),
2088
- testId,
2089
- workerIndex,
2090
- status: 'open',
2091
- openedTick: (state.tick += 1),
2092
- sealedTick: null,
2093
- outcome: null,
2094
- intervals: new Map(),
2095
- // Filled below: the session's DEDICATED observation-proxy port.
2096
- proxyUrl: null,
2097
- proxyServer: null,
2098
- claims,
2099
- // The registered expected-set identity this session was minted for
2100
- // (enforcement-review fix 2b); null when no expected set is bound.
2101
- registered: registered !== null
2102
- ? {
2103
- testId: registered.testId,
2104
- project: registered.project,
2105
- file: registered.file,
2106
- titlePath: [...registered.titlePath],
2107
- }
2108
- : null,
2109
- // Witness-side activity counter: incremented at every observation
2110
- // the witness itself makes under this session (records, intervals,
2111
- // exchanges, pre-observations). Diagnostic corroboration only —
2112
- // execution authority is the supervisor-observed trusted lifecycle,
2113
- // not this count.
2114
- activity: 0,
2115
- // Engine-browser surface registration (plan Phase 1 item 4): the
2116
- // validated consumer descriptor + loopback app base the engine
2117
- // drives for this session. Null until the fixture registers it.
2118
- engineSurface: null,
2119
- };
2120
- // Session attribution channel (plan Phase 1): a dedicated loopback
2121
- // proxy port whose traffic is attributed to THIS session. Exists only
2122
- // when an observation proxy is wired; otherwise nothing browser-side
2123
- // is attributable.
2124
- await startSessionProxy(state, session);
2125
- state.sessions.set(sessionId, session);
2126
- state.workerSessions.set(workerIndex, sessionId);
2127
- // Observe before-snapshots (Phase 2): the witness lists the
2128
- // observe-declared resources ITSELF at open. Total by construction —
2129
- // a snapshot failure is finalize data, never an open failure.
2130
- try {
2131
- await takeObserveSnapshots(state, session);
2132
- }
2133
- catch {
2134
- state.observeSnapshots.delete(sessionId);
2135
- }
2136
- sendJson(res, 200, sessionView(state, session));
2137
- }
2138
- /** The session view returned to supervisor and worker (the credential). */
2139
- function sessionView(state, session) {
2140
- void state;
2141
- return {
2142
- sessionId: session.sessionId,
2143
- sessionToken: session.token,
2144
- testId: session.testId,
2145
- workerIndex: session.workerIndex,
2146
- openedTick: session.openedTick,
2147
- proxyUrl: session.proxyUrl,
2148
- claims: [...session.claims],
2149
- };
2150
- }
2151
- /**
2152
- * `POST /sessions/close` (plan Phase 1; enforcement-review fix 3) —
2153
- * SUPERVISOR ONLY (enforced at dispatch): the supervisor seals the
2154
- * session with the observed outcome. Sealing is FINAL — every later
2155
- * submission, interval, or resolve for the session is rejected, so
2156
- * records cannot be injected after the test ended. Closing an unknown
2157
- * session fails (400); closing an already-sealed session is idempotent
2158
- * (safe re-delivery). The suite has NO reachable close path: the run
2159
- * token alone answers 403 before this handler runs.
2160
- */
2161
- async function handleSessionClose(state, res, body) {
2162
- if (!isPlainObject(body)) {
2163
- throw new HttpError(400, 'session close body must be an object');
2164
- }
2165
- const { sessionId, outcome } = body;
2166
- if (typeof sessionId !== 'string' || sessionId.length === 0) {
2167
- throw new HttpError(400, 'session close requires a sessionId');
2168
- }
2169
- if (outcome !== undefined && typeof outcome !== 'string') {
2170
- throw new HttpError(400, 'session close outcome, when present, must be a string');
2171
- }
2172
- const session = state.sessions.get(sessionId);
2173
- if (session === undefined) {
2174
- throw new HttpError(400, `session '${sessionId}' is unknown (never opened on this witness)`);
2175
- }
2176
- if (session.status === 'open') {
2177
- session.status = 'sealed';
2178
- session.sealedTick = (state.tick += 1);
2179
- session.outcome = typeof outcome === 'string' ? outcome : null;
2180
- state.workerSessions.delete(session.workerIndex);
2181
- // Observe snapshots die with the session: finalize runs BEFORE seal
2182
- // (the drain finalizes a passed test, then seals), so anything left
2183
- // here belongs to a test that never finalized — unsealed evidence
2184
- // must not linger for a later call to consume.
2185
- state.observeSnapshots.delete(sessionId);
2186
- // The dedicated channel dies with the session: nothing can observe
2187
- // (or submit) through it afterwards. The engine browser context dies
2188
- // too — a sealed session's pages are never driven again.
2189
- await stopSessionProxy(session);
2190
- await state.engineBrowser.closeSession(session.sessionId);
2191
- }
2192
- sendJson(res, 200, { sealed: true });
2193
- }
2194
- /**
2195
- * `POST /sessions/resolve` (plan Phase 1): the worker-side fixture asks
2196
- * for the session credential of the OPEN session bound to its exact
2197
- * (workerIndex, testId) pair. Only an open session answers — a sealed
2198
- * or never-opened session 404s — so the fixture can never obtain a
2199
- * credential for a session the supervisor did not open for exactly this
2200
- * test instance.
2201
- */
2202
- async function handleSessionResolve(state, res, body) {
2203
- if (!isPlainObject(body)) {
2204
- throw new HttpError(400, 'session resolve body must be an object');
2205
- }
2206
- const { testId, workerIndex } = body;
2207
- if (typeof testId !== 'string' || testId.length === 0) {
2208
- throw new HttpError(400, 'session resolve requires a non-empty testId');
2209
- }
2210
- if (typeof workerIndex !== 'number' || !Number.isInteger(workerIndex) || workerIndex < 0) {
2211
- throw new HttpError(400, 'session resolve requires a non-negative integer workerIndex');
2212
- }
2213
- const sessionId = state.workerSessions.get(workerIndex);
2214
- const session = sessionId === undefined ? undefined : state.sessions.get(sessionId);
2215
- if (session === undefined || session.status !== 'open' || session.testId !== testId) {
2216
- sendJson(res, 404, {
2217
- error: `no open session for (workerIndex ${String(workerIndex)}, testId '${testId}'); the ` +
2218
- 'supervisor (the gateforge reporter) opens a session per started test — run under ' +
2219
- 'the pack reporter so evidence primitives can resolve their session',
2220
- });
2221
- return;
2222
- }
2223
- sendJson(res, 200, sessionView(state, session));
2224
- }
2225
- /**
2226
- * Enforces the supervisor-issued session credential on a submission:
2227
- * the session must EXIST (403 otherwise), carry a token that matches
2228
- * (timing-safe), and still be OPEN (409 once sealed — late submissions
2229
- * after close are rejected fail closed).
2230
- */
2231
- function requireOpenSession(state, body) {
2232
- const sessionId = body['sessionId'];
2233
- const sessionToken = body['sessionToken'];
2234
- if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof sessionToken !== 'string') {
2235
- throw new HttpError(400, 'submissions require the supervisor-issued session credential (sessionId + sessionToken); ' +
2236
- 'run under the gateforge reporter so the test session is opened and resolved');
2237
- }
2238
- const session = state.sessions.get(sessionId);
2239
- if (session === undefined || !timingSafeEqual(sessionToken, session.token)) {
2240
- throw new HttpError(403, 'session credential is unknown to this witness: sessions are issued only by the ' +
2241
- 'supervisor channel (/sessions/open) and cannot be minted from the run token');
2242
- }
2243
- if (session.status !== 'open') {
2244
- throw new HttpError(409, `session for testId '${session.testId}' was sealed at tick ${String(session.sealedTick)}: ` +
2245
- 'late submissions after session close are rejected (no post-hoc record injection)');
2246
- }
2247
- return session;
2248
- }
2249
- /**
2250
- * Forces the submission's testId onto the session: the record's test
2251
- * identity is the supervisor-registered one, never a caller-declared
2252
- * value — a suite-supplied testId cannot assign evidence to another
2253
- * test (plan Phase 1 work item 2).
2254
- */
2255
- function requireSessionTestId(session, testId) {
2256
- if (testId !== session.testId) {
2257
- throw new HttpError(403, `submission testId '${String(testId)}' does not match the open session's ` +
2258
- `supervisor-registered testId '${session.testId}' — records bind to the session ` +
2259
- 'the supervisor opened, never to a caller-declared test id');
2260
- }
2261
- return session.testId;
2262
- }
2263
- /**
2264
- * Opens a UI-action observation interval on the witness clock (at most
2265
- * one open per session — opening auto-closes the previous). Shared by
2266
- * the suite-callable endpoint and the engine browser driver: both
2267
- * produce witness-stamped windows, never suite-supplied times.
2268
- */
2269
- function openActionInterval(state, session, operation) {
2270
- for (const interval of session.intervals.values()) {
2271
- if (interval.endTick === null)
2272
- interval.endTick = (state.tick += 1);
2273
- }
2274
- const intervalId = randomUUID();
2275
- const startTick = (state.tick += 1);
2276
- session.intervals.set(intervalId, { startTick, endTick: null, operation });
2277
- // Witness-side activity: a recorded UI-action interval is a
2278
- // witness-kept window; count it.
2279
- session.activity += 1;
2280
- return { intervalId, startTick };
2281
- }
2282
- /** Seals one UI-action observation interval (unknown/duplicate → throw). */
2283
- function closeActionInterval(state, session, intervalId) {
2284
- const interval = session.intervals.get(intervalId);
2285
- if (interval === undefined) {
2286
- throw new HttpError(400, `interval '${intervalId}' is unknown for this session`);
2287
- }
2288
- if (interval.endTick !== null) {
2289
- throw new HttpError(409, `interval '${intervalId}' is already closed (endTick ${String(interval.endTick)})`);
2290
- }
2291
- interval.endTick = (state.tick += 1);
2292
- return { startTick: interval.startTick, endTick: interval.endTick };
2293
- }
2294
- /**
2295
- * `POST /sessions/intervals/open`: the fixture marks the START of a
2296
- * UI-action observation interval on the witness's monotonic clock. At
2297
- * most one interval is open per session — opening a new one auto-closes
2298
- * the previous (a dangling interval must not silently widen the
2299
- * evidence window).
2300
- */
2301
- async function handleIntervalOpen(state, res, body) {
2302
- const session = requireOpenSession(state, body);
2303
- const operation = body['operation'];
2304
- if (typeof operation !== 'string' || operation.length === 0) {
2305
- throw new HttpError(400, 'interval open requires a non-empty operation label');
2306
- }
2307
- const { intervalId, startTick } = openActionInterval(state, session, operation);
2308
- sendJson(res, 200, { intervalId, startTick });
2309
- }
2310
- /**
2311
- * `POST /sessions/intervals/close`: seals a UI-action observation
2312
- * interval. Closing an unknown interval fails (400); closing twice is
2313
- * refused (409) — a sealed window must not be stretched after the fact.
2314
- */
2315
- async function handleIntervalClose(state, res, body) {
2316
- const session = requireOpenSession(state, body);
2317
- const intervalId = body['intervalId'];
2318
- if (typeof intervalId !== 'string' || intervalId.length === 0) {
2319
- throw new HttpError(400, 'interval close requires an intervalId');
2320
- }
2321
- const { startTick, endTick } = closeActionInterval(state, session, intervalId);
2322
- sendJson(res, 200, { startTick, endTick });
2323
- }
2324
- /**
2325
- * Whether an exchange observed at `tick` falls inside one of the
2326
- * session's recorded UI-action intervals (open intervals extend to the
2327
- * current moment — an observe arriving between action end and interval
2328
- * close still sees the window). Exchanges outside every interval are
2329
- * NEVER credited: that is setup traffic, not the browser action
2330
- * (plan Phase 1 item 6).
2331
- */
2332
- function tickWithinSessionInterval(session, tick) {
2333
- for (const interval of session.intervals.values()) {
2334
- if (tick >= interval.startTick && (interval.endTick === null || tick <= interval.endTick)) {
2335
- return true;
2336
- }
2337
- }
2338
- return false;
2339
- }
2340
- /**
2341
- * `POST /records`: validates and issues a submitted evidence record.
2342
- * Unknown primitive kinds → 400 (GF-11: an unregistered primitive name
2343
- * has no registration path; GF-14: the audit-event primitive is
2344
- * implementation-gated and does not exist yet). Only the two UI
2345
- * primitives are suite-submittable; `http.request` and persistence
2346
- * records are witness-issued only (engine-side observation), so no
2347
- * claimed-side path can mint them. Phase 1: issuance requires a valid
2348
- * OPEN session — the record's testId is forced onto the session's
2349
- * supervisor-registered value and the payload carries the session id,
2350
- * so every record self-describes the channel it was minted through.
2351
- */
2352
- async function handleRecords(state, res, body) {
2353
- if (!isPlainObject(body)) {
2354
- throw new HttpError(400, 'request body must be an object');
2355
- }
2356
- const { claimId, kind, payload, testId } = body;
2357
- if (typeof claimId !== 'string' || !OBLIGATION_ID_PATTERN.test(claimId)) {
2358
- throw new HttpError(400, "claimId must be an obligation id '<resourceId>:<contract>'");
2359
- }
2360
- if (typeof kind !== 'string' || !KNOWN_RECORD_KINDS.includes(kind)) {
2361
- throw new HttpError(400, `unknown evidence primitive '${String(kind)}'; accepted kinds: ${KNOWN_RECORD_KINDS.join(', ')} ` +
2362
- '(http.request and persistence records are witness-issued only: /witness/http-observation ' +
2363
- 'and /witness/persistence)');
2364
- }
2365
- if (typeof testId !== 'string' || testId.length === 0) {
2366
- throw new HttpError(400, 'testId must be a non-empty string');
2367
- }
2368
- if (!isPlainObject(payload)) {
2369
- throw new HttpError(400, 'payload must be a JSON object');
2370
- }
2371
- const session = requireOpenSession(state, body);
2372
- requireSessionTestId(session, testId);
2373
- const record = issueRecord(state, claimId, kind, session.testId, { ...payload, sessionId: session.sessionId }, 'suite-submitted');
2374
- // Witness-side activity (review recheck fix 2026-09-14): a submitted
2375
- // record under an open session is a session-bound event the witness
2376
- // issued; count it. (Content trust stays claimed — the count only
2377
- // corroborates session liveness for supervision, never evidence
2378
- // strength.)
2379
- session.activity += 1;
2380
- const response = {
2381
- recordId: record.recordId,
2382
- trust: record.trust,
2383
- runId: record.runId,
2384
- };
2385
- sendJson(res, 200, response);
2386
- }
2387
- /**
2388
- * `POST /witness/persistence`: runs the engine-side adapter (GET-only)
2389
- * for one entity, stamps a persistence record from the ADAPTER RESPONSE,
2390
- * and returns the verdict-relevant comparison. Attestation failures
2391
- * (GF-10 non-loopback base, GF-13 fingerprint mismatch) REJECT the
2392
- * record with 409 — raw adapter responses never leave this process.
2393
- *
2394
- * The issued record's payload is the ENGINE OBSERVATION the verdict
2395
- * engine grades postconditions against (audit rounds 4-5): `{resourceId,
2396
- * entityId, found, fields?, before?}`. Expectations NEVER come from the
2397
- * tested suite; `before` links a consumed pre-observation (id-set
2398
- * absence for create, entity-fields snapshot for update).
2399
- */
2400
- async function handlePersistence(state, res, body) {
2401
- if (!isPlainObject(body)) {
2402
- throw new HttpError(400, 'request body must be an object');
2403
- }
2404
- const { resourceId, entityId, testId, claimId, preObservationId } = body;
2405
- if (typeof resourceId !== 'string' || resourceId.length === 0) {
2406
- throw new HttpError(400, 'resourceId must be a non-empty string');
2407
- }
2408
- if (typeof testId !== 'string' || testId.length === 0) {
2409
- throw new HttpError(400, 'testId must be a non-empty string');
2410
- }
2411
- if (typeof claimId !== 'string' || !OBLIGATION_ID_PATTERN.test(claimId)) {
2412
- throw new HttpError(400, "claimId must be an obligation id '<resourceId>:<contract>'");
2413
- }
2414
- if (preObservationId !== undefined &&
2415
- (typeof preObservationId !== 'string' || preObservationId.length === 0)) {
2416
- throw new HttpError(400, 'preObservationId must be a non-empty string when present');
2417
- }
2418
- // Phase 1: engine-side reads run under the supervisor-opened session,
2419
- // so the persistence record binds to the same channel the UI action
2420
- // used (and the record's testId is the session's, never caller-declared).
2421
- const session = requireOpenSession(state, body);
2422
- const boundTestId = requireSessionTestId(session, testId);
2423
- const boundClaimId = String(claimId);
2424
- const { adapterName, adapter, baseUrl } = await adapterReadContext(state, resourceId);
2425
- // Consume the referenced pre-observation, if any (single-use). Its
2426
- // contents — never suite-declared expectations — are what the engine
2427
- // grades create/update postconditions against.
2428
- let before;
2429
- let consumed;
2430
- if (typeof preObservationId === 'string') {
2431
- const observation = state.preObservations.get(preObservationId);
2432
- if (observation === undefined || observation.resourceId !== resourceId) {
2433
- throw new HttpError(400, `pre-observation '${preObservationId}' is unknown, already consumed, or belongs to another resource`);
2434
- }
2435
- state.preObservations.delete(preObservationId);
2436
- consumed = observation;
2437
- before = { entityAbsent: true }; // refined below for ids-kind snapshots
2438
- }
2439
- // Execute the adapter's GET-only read through the mediated transport.
2440
- const adapterHeaders = state.options.adapterReadAuthorization
2441
- ? { authorization: state.options.adapterReadAuthorization }
2442
- : undefined;
2443
- const ctx = makeAdapterContext(baseUrl, resourceId, (path) => adapterGet(baseUrl, state.options.requestTimeoutMs, path, state.options.adapterReadAuthorization), adapterHeaders);
2444
- let bodyRaw;
2445
- try {
2446
- bodyRaw = await adapter.read(ctx, entityId);
2447
- }
2448
- catch (error) {
2449
- throw new HttpError(409, `adapter '${adapterName}' read failed: ${error instanceof Error ? error.message : String(error)}`);
2450
- }
2451
- const found = bodyRaw !== null && bodyRaw !== undefined;
2452
- let normalized = null;
2453
- if (found) {
2454
- try {
2455
- const candidate = adapter.normalize(bodyRaw);
2456
- if (!isPlainObject(candidate) || !('entityId' in candidate) || !('fields' in candidate)) {
2457
- throw new Error('normalize must return {entityId, fields}');
2458
- }
2459
- normalized = { entityId: candidate['entityId'], fields: candidate['fields'] };
2460
- }
2461
- catch (error) {
2462
- throw new HttpError(409, `adapter '${adapterName}' normalize failed: ${error instanceof Error ? error.message : String(error)}`);
2463
- }
2464
- }
2465
- // Report-only observations; the ENGINE judges identity binding (GF-05).
2466
- const mismatches = [];
2467
- let entityAgrees = true;
2468
- if (found && entityId !== undefined && normalized !== null) {
2469
- try {
2470
- if (canonicalOf(entityId) !== canonicalOf(normalized.entityId)) {
2471
- entityAgrees = false;
2472
- mismatches.push(`entityId mismatch: adapter returned ${canonicalOf(normalized.entityId)} for requested ${canonicalOf(entityId)}`);
2473
- }
2474
- }
2475
- catch {
2476
- entityAgrees = false;
2477
- }
2478
- }
2479
- // Build `before` from the consumed pre-observation's OWN contents —
2480
- // never from anything the suite declared.
2481
- if (consumed !== undefined && before !== undefined) {
2482
- if (consumed.kind === 'ids') {
2483
- const observedId = normalized !== null && normalized.entityId !== undefined ? normalized.entityId : entityId;
2484
- let absent = true;
2485
- try {
2486
- absent = !consumed.ids.includes(canonicalOf(observedId));
2487
- }
2488
- catch {
2489
- absent = true; // unrepresentable id: treat as not previously observed
2490
- }
2491
- before = { entityAbsent: absent };
2492
- }
2493
- else {
2494
- before = {
2495
- found: consumed.found,
2496
- ...(consumed.found ? { fields: consumed.fields } : {}),
2497
- };
2498
- }
2499
- }
2500
- // The payload IS the engine observation (hashed into the record id).
2501
- const payload = {
2502
- resourceId,
2503
- entityId: found && normalized !== null ? normalized.entityId : entityId ?? null,
2504
- found,
2505
- ...(found && normalized !== null ? { fields: normalized.fields } : {}),
2506
- ...(before !== undefined ? { before } : {}),
2507
- sessionId: session.sessionId,
2508
- };
2509
- const issued = issuePersistenceRecord(state, boundClaimId, boundTestId, payload);
2510
- // Witness-side activity (review recheck fix 2026-09-14): an
2511
- // engine-observed persistence read under the session is real work the
2512
- // witness performed; count it so supervision can corroborate execution.
2513
- session.activity += 1;
2514
- const response = {
2515
- recordId: issued.recordId,
2516
- runId: issued.runId,
2517
- verdictRelevant: {
2518
- found,
2519
- fieldsMatch: entityAgrees,
2520
- ...(mismatches.length > 0 ? { mismatches } : {}),
2521
- },
2522
- };
2523
- sendJson(res, 200, response);
2524
- }
2525
- /**
2526
- * `POST /witness/pre-observation` (audit rounds 4-5): takes an
2527
- * engine-side snapshot BEFORE a claimed action, stored in witness
2528
- * memory and consumed single-use by the paired persistence read. Two
2529
- * modes:
2530
- * - with `entityId`: snapshots that entity's observed fields (for
2531
- * update postconditions — the engine grades the before/after delta);
2532
- * - without: snapshots the resource's observed id set via the adapter's
2533
- * optional `list` (for create postconditions — the engine grades
2534
- * absence-before).
2535
- * A suite can reference a real observation but cannot fabricate,
2536
- * replay, or mutate its contents.
2537
- */
2538
- async function handlePreObservation(state, res, body) {
2539
- if (!isPlainObject(body)) {
2540
- throw new HttpError(400, 'request body must be an object');
2541
- }
2542
- const { resourceId, testId, claimId, entityId } = body;
2543
- if (typeof resourceId !== 'string' || resourceId.length === 0) {
2544
- throw new HttpError(400, 'resourceId must be a non-empty string');
2545
- }
2546
- if (typeof testId !== 'string' || testId.length === 0) {
2547
- throw new HttpError(400, 'testId must be a non-empty string');
2548
- }
2549
- if (typeof claimId !== 'string' || !OBLIGATION_ID_PATTERN.test(claimId)) {
2550
- throw new HttpError(400, "claimId must be an obligation id '<resourceId>:<contract>'");
2551
- }
2552
- // Phase 1: pre-observations belong to the supervisor-opened session of
2553
- // the claiming test (the persistence read later consumes them under
2554
- // the same session).
2555
- const session = requireOpenSession(state, body);
2556
- requireSessionTestId(session, testId);
2557
- const { observationId, observed } = await takePreObservation(state, session, resourceId, entityId);
2558
- const response = { observationId, observed };
2559
- sendJson(res, 200, response);
2560
- }
2561
- /**
2562
- * Takes an engine-side pre-observation snapshot (shared by the
2563
- * suite-callable endpoint and the engine browser driver): with
2564
- * `entityId` an entity-fields snapshot (update postconditions),
2565
- * without it the resource id-set snapshot (create postconditions).
2566
- * Snapshots live in witness memory, single-use, and their contents —
2567
- * never suite-declared expectations — are what the engine grades
2568
- * against. A suite can reference a real observation but cannot
2569
- * fabricate, replay, or mutate its contents.
2570
- */
2571
- async function takePreObservation(state, session, resourceId, entityId) {
2572
- const { adapterName, adapter, baseUrl } = await adapterReadContext(state, resourceId);
2573
- const adapterHeaders = state.options.adapterReadAuthorization
2574
- ? { authorization: state.options.adapterReadAuthorization }
2575
- : undefined;
2576
- const ctx = makeAdapterContext(baseUrl, resourceId, (path) => adapterGet(baseUrl, state.options.requestTimeoutMs, path, state.options.adapterReadAuthorization), adapterHeaders);
2577
- const observationId = randomUUID();
2578
- if (entityId !== undefined) {
2579
- // Entity-fields snapshot (update postconditions).
2580
- let bodyRaw;
2581
- try {
2582
- bodyRaw = await adapter.read(ctx, entityId);
2583
- }
2584
- catch (error) {
2585
- throw new HttpError(409, `adapter '${adapterName}' read failed: ${error instanceof Error ? error.message : String(error)}`);
2586
- }
2587
- const found = bodyRaw !== null && bodyRaw !== undefined;
2588
- let fields = undefined;
2589
- if (found) {
2590
- try {
2591
- const candidate = adapter.normalize(bodyRaw);
2592
- if (!isPlainObject(candidate) || !('fields' in candidate)) {
2593
- throw new Error('normalize must return {entityId, fields}');
2594
- }
2595
- fields = candidate['fields'];
2596
- }
2597
- catch (error) {
2598
- throw new HttpError(409, `adapter '${adapterName}' normalize failed during pre-observation: ${error instanceof Error ? error.message : String(error)}`);
2599
- }
2600
- }
2601
- state.preObservations.set(observationId, {
2602
- resourceId,
2603
- kind: 'entity',
2604
- entityId: canonicalOf(entityId),
2605
- found,
2606
- ...(found ? { fields } : {}),
2607
- });
2608
- // Witness-side activity: the engine-side snapshot ran under the
2609
- // session; count it.
2610
- session.activity += 1;
2611
- return { observationId, observed: found ? 1 : 0 };
2612
- }
2613
- // Resource id-set snapshot (create postconditions).
2614
- if (typeof adapter.list !== 'function') {
2615
- throw new HttpError(409, `adapter '${adapterName}' does not support resource-level pre-observation ` +
2616
- '(no list export); create postconditions cannot be observed for this resource');
2617
- }
2618
- let raw;
2619
- try {
2620
- raw = await adapter.list(ctx);
2621
- }
2622
- catch (error) {
2623
- throw new HttpError(409, `adapter '${adapterName}' list failed: ${error instanceof Error ? error.message : String(error)}`);
2624
- }
2625
- if (!Array.isArray(raw)) {
2626
- throw new HttpError(409, `adapter '${adapterName}' list must return an array of entities`);
2627
- }
2628
- const ids = [];
2629
- for (const entity of raw) {
2630
- try {
2631
- const candidate = adapter.normalize(entity);
2632
- if (!isPlainObject(candidate) || !('entityId' in candidate)) {
2633
- throw new Error('normalize must return {entityId, fields}');
2634
- }
2635
- ids.push(canonicalOf(candidate['entityId']));
2636
- }
2637
- catch (error) {
2638
- throw new HttpError(409, `adapter '${adapterName}' normalize failed during pre-observation: ${error instanceof Error ? error.message : String(error)}`);
2639
- }
2640
- }
2641
- state.preObservations.set(observationId, { resourceId, kind: 'ids', ids: ids.sort(compareStrings) });
2642
- // Witness-side activity: the engine-side id-set snapshot ran under
2643
- // the session; count it.
2644
- session.activity += 1;
2645
- return { observationId, observed: ids.length };
2646
- }
2647
- /**
2648
- * `POST /runs/server-e2e-declarations` — SUPERVISOR ONLY (verifier key;
2649
- * the same authority as `POST /runs/expected-set`): registers the
2650
- * obligation ids the trusted mapping layer declared kind `server-e2e`.
2651
- * This is the gate that makes the server-witnessed channel kind-honest:
2652
- * the witness refuses (`409`) any server intent whose claimId is not in
2653
- * this set, so a browser-kind claim can never be satisfied through the
2654
- * channel and a suite-written intent can never self-declare its kind
2655
- * (the kind resolves in the trusted CLI mapping layer, which is exactly
2656
- * why the fact enters through a verifier-key surface, never through the
2657
- * suite-writable spool). Bound once BEFORE any issuance — identical
2658
- * re-registration is idempotent, any change or late registration is 409.
2659
- */
2660
- async function handleServerE2eDeclarations(state, res, verifier, body) {
2661
- requireSupervisor(state, verifier);
2662
- if (!isPlainObject(body) || !Array.isArray(body['obligations'])) {
2663
- throw new HttpError(400, 'server-e2e declarations body must be {obligations: [...]}');
2664
- }
2665
- const obligations = new Set();
2666
- for (const entry of body['obligations']) {
2667
- if (typeof entry !== 'string' || !OBLIGATION_ID_PATTERN.test(entry)) {
2668
- throw new HttpError(400, `server-e2e declarations must be obligation ids '<resourceId>:<contract>' (got '${String(entry)}')`);
2669
- }
2670
- obligations.add(entry);
2671
- }
2672
- if (state.serverE2eDeclarations !== null) {
2673
- const identical = state.serverE2eDeclarations.size === obligations.size &&
2674
- [...obligations].every((id) => state.serverE2eDeclarations?.has(id));
2675
- if (identical) {
2676
- sendJson(res, 200, {
2677
- bound: true,
2678
- count: state.serverE2eDeclarations.size,
2679
- obligations: [...state.serverE2eDeclarations].sort(compareStrings),
2680
- });
2681
- return;
2682
- }
2683
- sendJson(res, 409, {
2684
- error: 'server-e2e declarations are already bound to this run and differ; the declaration set ' +
2685
- 'is a PRE-run fact and is never relabeled — start a fresh witness for a new invocation',
2686
- });
2687
- return;
2688
- }
2689
- if (state.ledger.size > 0 || state.sessions.size > 0 || state.serverPreObservations.size > 0) {
2690
- sendJson(res, 409, {
2691
- error: 'witness already issued evidence or holds open sessions; server-e2e declarations must be ' +
2692
- 'registered BEFORE the run — start a fresh witness for a new invocation',
2693
- });
2694
- return;
2695
- }
2696
- state.serverE2eDeclarations = obligations;
2697
- sendJson(res, 200, {
2698
- bound: true,
2699
- count: obligations.size,
2700
- obligations: [...obligations].sort(compareStrings),
2701
- });
2702
- }
2703
- /**
2704
- * `POST /runs/observe-declarations` — SUPERVISOR ONLY: registers the
2705
- * obligation ids the trusted mapping layer declared kind `observed-e2e`
2706
- * (Observe channel, Phase 2) BEFORE the run. Same binding contract as
2707
- * the server-e2e set: bound once, identical re-registration idempotent,
2708
- * any change or late registration refused — the witness stamps
2709
- * `channel: 'observe'` records for these obligations only.
2710
- */
2711
- async function handleObserveDeclarations(state, res, verifier, body) {
2712
- requireSupervisor(state, verifier);
2713
- if (!isPlainObject(body) || !Array.isArray(body['obligations'])) {
2714
- throw new HttpError(400, 'observe declarations body must be {obligations: [...]}');
2715
- }
2716
- const obligations = new Set();
2717
- for (const entry of body['obligations']) {
2718
- if (typeof entry !== 'string' || !OBLIGATION_ID_PATTERN.test(entry)) {
2719
- throw new HttpError(400, `observe declarations must be obligation ids '<resourceId>:<contract>' (got '${String(entry)}')`);
2720
- }
2721
- obligations.add(entry);
2722
- }
2723
- if (state.observeDeclarations !== null) {
2724
- const identical = state.observeDeclarations.size === obligations.size &&
2725
- [...obligations].every((id) => state.observeDeclarations?.has(id));
2726
- if (identical) {
2727
- sendJson(res, 200, {
2728
- bound: true,
2729
- count: state.observeDeclarations.size,
2730
- obligations: [...state.observeDeclarations].sort(compareStrings),
2731
- });
2732
- return;
2733
- }
2734
- sendJson(res, 409, {
2735
- error: 'observe declarations are already bound to this run and differ; the declaration set ' +
2736
- 'is a PRE-run fact and is never relabeled — start a fresh witness for a new invocation',
2737
- });
2738
- return;
2739
- }
2740
- if (state.ledger.size > 0 || state.sessions.size > 0) {
2741
- sendJson(res, 409, {
2742
- error: 'witness already issued evidence or holds open sessions; observe declarations must be ' +
2743
- 'registered BEFORE the run — start a fresh witness for a new invocation',
2744
- });
2745
- return;
2746
- }
2747
- state.observeDeclarations = obligations;
2748
- sendJson(res, 200, {
2749
- bound: true,
2750
- count: obligations.size,
2751
- obligations: [...obligations].sort(compareStrings),
2752
- });
2753
- }
2754
- /** Splits `<resourceId>:<contract>` at the first colon (obligation id grammar). */
2755
- function resourceIdOfObligation(obligationId) {
2756
- const colon = obligationId.indexOf(':');
2757
- return colon === -1 ? obligationId : obligationId.slice(0, colon);
2758
- }
2759
- /** The CRUD operation a `persistence:<op>` contract requires; null otherwise. */
2760
- function observeOperation(contract) {
2761
- if (!contract.startsWith('persistence:'))
2762
- return null;
2763
- const operation = contract.slice('persistence:'.length);
2764
- if (operation === 'create' || operation === 'read' || operation === 'update' || operation === 'delete') {
2765
- return operation;
2766
- }
2767
- return null;
2768
- }
2769
- /**
2770
- * Takes Observe before-snapshots for one freshly opened session: for
2771
- * every resource its observe-declared claims name, the witness runs the
2772
- * resource's adapter `list()` ITSELF and normalizes each body. The
2773
- * snapshot is the before-state every observe postcondition grades
2774
- * against — expectations never come from the suite. Per-resource
2775
- * trouble (no adapter, no list, probe/read/normalize failure) is
2776
- * stored as an error snapshot: the session still opens and the test
2777
- * still runs; finalize reports the resource as unobservable instead of
2778
- * satisfying anything. Total: never throws out of session open.
2779
- */
2780
- async function takeObserveSnapshots(state, session) {
2781
- if (state.observeDeclarations === null || state.observeDeclarations.size === 0)
2782
- return;
2783
- const resources = new Set();
2784
- for (const claim of session.claims) {
2785
- if (state.observeDeclarations.has(claim))
2786
- resources.add(resourceIdOfObligation(claim));
2787
- }
2788
- if (resources.size === 0)
2789
- return;
2790
- const perSession = new Map();
2791
- state.observeSnapshots.set(session.sessionId, perSession);
2792
- for (const resourceId of [...resources].sort(compareStrings)) {
2793
- perSession.set(resourceId, await snapshotObserveResource(state, resourceId));
2794
- }
2795
- session.activity += 1;
2796
- }
2797
- /** Snapshots one resource's adapter-listed entities (never throws). */
2798
- async function snapshotObserveResource(state, resourceId) {
2799
- const failure = (adapterName, error) => ({
2800
- resourceId,
2801
- adapterName,
2802
- before: new Map(),
2803
- error,
2804
- });
2805
- let adapterName = resourceId;
2806
- try {
2807
- const context = await adapterReadContext(state, resourceId);
2808
- adapterName = context.adapterName;
2809
- const adapter = context.adapter;
2810
- if (typeof adapter.list !== 'function') {
2811
- return failure(adapterName, `adapter '${adapterName}' exports no list() — Observe needs a before-snapshot, so ` +
2812
- `resource '${resourceId}' is unobservable until the adapter lists its entities`);
2813
- }
2814
- const ctx = observeAdapterContext(state, context.baseUrl, resourceId);
2815
- return { resourceId, adapterName, before: await observeListEntities(adapterName, adapter, ctx), error: null };
2816
- }
2817
- catch (error) {
2818
- const detail = error instanceof HttpError ? error.message : error.message;
2819
- return failure(adapterName, detail);
2820
- }
2821
- }
2822
- /** Builds the GET-only adapter transport for observe snapshots/reads. */
2823
- function observeAdapterContext(state, baseUrl, resourceId) {
2824
- const headers = state.options.adapterReadAuthorization
2825
- ? { authorization: state.options.adapterReadAuthorization }
2826
- : undefined;
2827
- return makeAdapterContext(baseUrl, resourceId, (path) => adapterGet(baseUrl, state.options.requestTimeoutMs, path, state.options.adapterReadAuthorization), headers);
2828
- }
2829
- /**
2830
- * Lists + normalizes a resource's entities through its adapter (the
2831
- * witness's own observation). Throws HttpError (409) on list/normalize
2832
- * trouble — callers turn it into a typed observe note, never
2833
- * satisfaction.
2834
- */
2835
- async function observeListEntities(adapterName, adapter, ctx) {
2836
- if (typeof adapter.list !== 'function') {
2837
- throw new HttpError(409, `adapter '${adapterName}' exports no list() — Observe needs entity snapshots`);
2838
- }
2839
- let listed;
2840
- try {
2841
- listed = await adapter.list(ctx);
2842
- }
2843
- catch (error) {
2844
- throw new HttpError(409, `adapter '${adapterName}' list failed: ${error.message}`);
2845
- }
2846
- if (!Array.isArray(listed)) {
2847
- throw new HttpError(409, `adapter '${adapterName}' list must return an array of entities`);
2848
- }
2849
- const out = new Map();
2850
- for (const raw of listed) {
2851
- let normalized;
2852
- try {
2853
- const candidate = adapter.normalize(raw);
2854
- if (!isPlainObject(candidate) || !('entityId' in candidate) || !('fields' in candidate)) {
2855
- throw new Error('normalize must return {entityId, fields}');
2856
- }
2857
- normalized = { entityId: candidate['entityId'], fields: candidate['fields'] };
2858
- }
2859
- catch (error) {
2860
- throw new HttpError(409, `adapter '${adapterName}' normalize failed: ${error.message}`);
2861
- }
2862
- let key;
2863
- try {
2864
- key = canonicalOf(normalized.entityId);
2865
- }
2866
- catch {
2867
- throw new HttpError(409, `adapter '${adapterName}' normalized an entity id with no canonical form`);
2868
- }
2869
- out.set(key, normalized);
2870
- }
2871
- return out;
2872
- }
2873
- /**
2874
- * Matches a recorded observed path against an adapter observe path
2875
- * template. `{id}` binds exactly one non-empty segment; every other
2876
- * segment must be literally equal (case-sensitive). Matching reuses
2877
- * core's canonical shape semantics (`{id}` → `{}`).
2878
- *
2879
- * Returns `{id}` (null for id-less create templates) on match, null
2880
- * otherwise.
2881
- */
2882
- function matchObserveTemplate(observedPath, template) {
2883
- const segments = template.split('/').filter((segment) => segment.length > 0);
2884
- const idIndex = segments.indexOf('{id}');
2885
- const canonical = segments.map((segment) => (segment === '{id}' ? '{}' : segment)).join('/');
2886
- if (!pathMatchesShape(observedPath, canonical.startsWith('/') ? canonical : `/${canonical}`)) {
2887
- return null;
2888
- }
2889
- if (idIndex === -1)
2890
- return { id: null };
2891
- const observedSegments = observedPath.split('/').filter((segment) => segment.length > 0);
2892
- const id = observedSegments[idIndex];
2893
- if (id === undefined || id.length === 0)
2894
- return null;
2895
- return { id };
2896
- }
2897
- /** Finds a snapshot key for a path id segment (canonical or numeric-string form). */
2898
- function beforeKeyForSegment(before, segment) {
2899
- try {
2900
- const canonical = canonicalOf(segment);
2901
- if (before.has(canonical))
2902
- return canonical;
2903
- }
2904
- catch {
2905
- return null;
2906
- }
2907
- for (const [key, entry] of before) {
2908
- if (typeof entry.entityId === 'number' && String(entry.entityId) === segment)
2909
- return key;
2910
- }
2911
- return null;
2912
- }
2913
- /**
2914
- * Parses a proxied request body into echoable fields (Observe channel):
2915
- * JSON objects and form bodies project their top-level scalar
2916
- * (string/number/boolean) fields — the witness-observed statement of
2917
- * what the test sent, graded by echo against the adapter read. Nested
2918
- * envelopes are not entity fields and are skipped (documented); empty,
2919
- * truncated, oversized, unparsable, or otherwise-typed bodies are
2920
- * INELIGIBLE (typed error), never echoed from a prefix or a guess.
2921
- */
2922
- function parseObserveBody(exchange) {
2923
- if (exchange.requestBody === null || exchange.requestBytes === 0) {
2924
- return { error: 'the proxied exchange carried no request body — there is nothing to echo' };
2925
- }
2926
- if (exchange.requestTruncated) {
2927
- return {
2928
- error: `the request body exceeds the ${String(OBSERVED_REQUEST_BODY_BYTES)}-byte witness snapshot ` +
2929
- 'cap — oversized intents are never echoed from a prefix',
2930
- };
2931
- }
2932
- const contentType = exchange.requestContentType;
2933
- if (contentType === 'application/json') {
2934
- let parsed;
2935
- try {
2936
- parsed = JSON.parse(exchange.requestBody.toString('utf8'));
2937
- }
2938
- catch {
2939
- return { error: 'the request body is not parseable JSON' };
2940
- }
2941
- if (!isPlainObject(parsed)) {
2942
- return { error: 'the JSON request body is not an object' };
2943
- }
2944
- const fields = {};
2945
- for (const [key, value] of Object.entries(parsed)) {
2946
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
2947
- fields[key] = value;
2948
- }
2949
- }
2950
- if (Object.keys(fields).length === 0) {
2951
- return { error: 'the JSON request body carries no echoable scalar fields' };
2952
- }
2953
- return { fields };
2954
- }
2955
- if (contentType === 'application/x-www-form-urlencoded') {
2956
- const fields = {};
2957
- for (const [key, value] of new URLSearchParams(exchange.requestBody.toString('utf8'))) {
2958
- fields[key] = value;
2959
- }
2960
- if (Object.keys(fields).length === 0) {
2961
- return { error: 'the form request body carries no fields' };
2962
- }
2963
- return { fields };
2964
- }
2965
- return {
2966
- error: `unsupported request content-type '${contentType ?? '<none>'}' — observe echoes JSON and ` +
2967
- 'form bodies only',
2968
- };
2969
- }
2970
- /**
2971
- * Reads one entity through its adapter at finalize time (the
2972
- * witness's own after-observation). Throws HttpError (409) on
2973
- * adapter/normalize trouble — callers note it, never satisfy on it.
2974
- */
2975
- async function readObserveEntity(state, resourceId, id) {
2976
- const { adapterName, adapter, baseUrl } = await adapterReadContext(state, resourceId);
2977
- const ctx = observeAdapterContext(state, baseUrl, resourceId);
2978
- let bodyRaw;
2979
- try {
2980
- bodyRaw = await adapter.read(ctx, id);
2981
- }
2982
- catch (error) {
2983
- throw new HttpError(409, `adapter '${adapterName}' read failed: ${error.message}`);
2984
- }
2985
- const found = bodyRaw !== null && bodyRaw !== undefined;
2986
- if (!found)
2987
- return { adapterName, found: false, fields: null, entityId: id };
2988
- try {
2989
- const candidate = adapter.normalize(bodyRaw);
2990
- if (!isPlainObject(candidate) || !('entityId' in candidate) || !('fields' in candidate)) {
2991
- throw new Error('normalize must return {entityId, fields}');
2992
- }
2993
- return { adapterName, found: true, fields: candidate['fields'], entityId: candidate['entityId'] };
2994
- }
2995
- catch (error) {
2996
- throw new HttpError(409, `adapter '${adapterName}' normalize failed: ${error.message}`);
2997
- }
2998
- }
2999
- /**
3000
- * `POST /observe/finalize` — SUPERVISOR ONLY: resolves one OPEN
3001
- * session's observe-declared claims against the session's own proxied
3002
- * traffic plus independent adapter reads, stamping witnessed
3003
- * `persistence.observed` records for whatever resolves. The session
3004
- * must be OPEN (the drain finalizes after a passed test, before seal);
3005
- * sealed/unknown sessions are refused, so records are never injected
3006
- * after the test ended. Every non-resolution is a typed NOTE in the
3007
- * response — never satisfaction, never a run failure (the obligation
3008
- * stays blocking through verdicts, which is the honest outcome).
3009
- *
3010
- * Per obligation (`<resourceId>:persistence:<op>`):
3011
- * - adapter binding + before-snapshot must exist (else typed note);
3012
- * - exactly one 2xx session exchange must match the binding (zero →
3013
- * missing-traffic note; several → ambiguity note);
3014
- * - create resolves its id from the list-diff (exactly one new entity);
3015
- * read/update/delete bind `{id}` from the path against the snapshot;
3016
- * - create/update echo the parsed request-body scalars against the
3017
- * adapter read (the record carries both; the ENGINE grades the echo);
3018
- * - the matched exchange is consumed single-use.
3019
- */
3020
- async function handleObserveFinalize(state, res, verifier, body) {
3021
- requireSupervisor(state, verifier);
3022
- if (!isPlainObject(body) || typeof body['sessionId'] !== 'string' || body['sessionId'].length === 0) {
3023
- throw new HttpError(400, 'observe finalize body must be {sessionId}');
3024
- }
3025
- const sessionId = body['sessionId'];
3026
- const session = state.sessions.get(sessionId);
3027
- if (session === undefined) {
3028
- throw new HttpError(400, `session '${sessionId}' is unknown (never opened on this witness)`);
3029
- }
3030
- if (session.status !== 'open') {
3031
- throw new HttpError(409, `session '${sessionId}' is sealed — observe finalizes before seal, never after (records cannot be injected after the test ended)`);
3032
- }
3033
- if (state.observeDeclarations === null) {
3034
- throw new HttpError(409, 'observe declarations are not bound on this witness — register them before the run');
3035
- }
3036
- const finalized = [];
3037
- const notes = [];
3038
- const claims = session.claims.filter((claim) => state.observeDeclarations?.has(claim));
3039
- for (const claimId of claims) {
3040
- const outcome = await finalizeObserveClaim(state, session, claimId);
3041
- if ('record' in outcome)
3042
- finalized.push(outcome.record);
3043
- else
3044
- notes.push(outcome.note);
3045
- }
3046
- const response = { finalized, notes };
3047
- sendJson(res, 200, response);
3048
- }
3049
- /** Resolves one observe-declared claim (record or typed note, never throws). */
3050
- async function finalizeObserveClaim(state, session, claimId) {
3051
- const note = (detail) => ({ note: `observe '${claimId}': ${detail}` });
3052
- const resourceId = resourceIdOfObligation(claimId);
3053
- const operation = observeOperation(claimId.slice(resourceId.length + 1));
3054
- if (operation === null) {
3055
- return note('the Observe channel proves persistence:* contracts only — this claim stays blocking');
3056
- }
3057
- let adapterName;
3058
- let adapter;
3059
- let adapterBaseUrl;
3060
- try {
3061
- const context = await adapterReadContext(state, resourceId);
3062
- adapterName = context.adapterName;
3063
- adapter = context.adapter;
3064
- adapterBaseUrl = context.baseUrl;
3065
- }
3066
- catch (error) {
3067
- return note(error instanceof HttpError ? error.message : error.message);
3068
- }
3069
- const binding = adapter.observe?.[operation];
3070
- if (binding === undefined) {
3071
- return note(`adapter '${adapterName}' declares no observe binding for '${operation}' — declare it in ` +
3072
- `'.gateforge/adapters/${adapterName}.mjs' to make this obligation observable`);
3073
- }
3074
- const snapshot = state.observeSnapshots.get(session.sessionId)?.get(resourceId);
3075
- if (snapshot === undefined || snapshot.error !== null) {
3076
- return note(snapshot?.error !== null && snapshot?.error !== undefined
3077
- ? `no usable before-snapshot: ${snapshot.error}`
3078
- : 'no before-snapshot for this session — the session opened before observe declarations bound, or the snapshot failed');
3079
- }
3080
- // Bind watermark (plan §11.4, same as http-observation): exchanges
3081
- // that completed before the trusted context bound predate it.
3082
- const watermark = state.runContext === null ? 0 : state.observedSeqAtBind;
3083
- const matches = [];
3084
- for (const exchange of state.observed) {
3085
- if (exchange.sessionId !== session.sessionId || exchange.seq <= watermark)
3086
- continue;
3087
- if (exchange.method !== binding.method)
3088
- continue;
3089
- if (exchange.status < 200 || exchange.status > 299)
3090
- continue;
3091
- const matched = matchObserveTemplate(exchange.path, binding.path);
3092
- if (matched === null)
3093
- continue;
3094
- matches.push({ exchange, id: matched.id });
3095
- }
3096
- if (matches.length === 0) {
3097
- return note(`no ${binding.method} ${binding.path} exchange (2xx) for this session through the observation ` +
3098
- 'proxy — drive traffic through the session proxy prefix before claiming the obligation');
3099
- }
3100
- if (matches.length > 1) {
3101
- return note(`${String(matches.length)} matching ${binding.method} ${binding.path} exchanges — ambiguous, ` +
3102
- 'refusing to pick one (seed through untracked channels so the mutation stands alone)');
3103
- }
3104
- const matched = matches[0];
3105
- // Resolve the entity id: create diffs the witness-held lists (the new
3106
- // id is observed, never declared); read/update/delete bind `{id}`
3107
- // against the session-open snapshot.
3108
- let entityIdForRead;
3109
- let before;
3110
- if (operation === 'create') {
3111
- let after;
3112
- try {
3113
- const ctx = observeAdapterContext(state, adapterBaseUrl, resourceId);
3114
- after = await observeListEntities(adapterName, adapter, ctx);
3115
- }
3116
- catch (error) {
3117
- return note(error instanceof HttpError ? error.message : error.message);
3118
- }
3119
- const fresh = [...after.keys()].filter((key) => !snapshot.before.has(key));
3120
- if (fresh.length !== 1) {
3121
- return note(`expected exactly one new entity after the observed create, found ${String(fresh.length)} — ` +
3122
- 'the creation is ambiguous, so no record is issued');
3123
- }
3124
- const created = after.get(fresh[0]);
3125
- entityIdForRead = created.entityId;
3126
- before = { entityAbsent: true };
3127
- }
3128
- else {
3129
- if (matched.id === null) {
3130
- return note('the observe binding carries no {id} segment for a non-create operation');
3131
- }
3132
- const beforeKey = beforeKeyForSegment(snapshot.before, matched.id);
3133
- if (beforeKey === null) {
3134
- return note(`entity '${matched.id}' was not in the session-open snapshot — observe binds {id} ` +
3135
- 'against witness-held before-state, never against suite-declared ids');
3136
- }
3137
- const beforeEntry = snapshot.before.get(beforeKey);
3138
- entityIdForRead = beforeEntry.entityId;
3139
- if (operation === 'update')
3140
- before = { found: true, fields: beforeEntry.fields };
3141
- }
3142
- // Echo source (create/update only): the witness-observed request
3143
- // fields. Read/delete carry no echo — presence/absence grades them.
3144
- let observedFields = {};
3145
- if (operation === 'create' || operation === 'update') {
3146
- const parsed = parseObserveBody(matched.exchange);
3147
- if ('error' in parsed)
3148
- return note(parsed.error);
3149
- observedFields = parsed.fields;
3150
- }
3151
- let read;
3152
- try {
3153
- read = await readObserveEntity(state, resourceId, entityIdForRead);
3154
- }
3155
- catch (error) {
3156
- return note(error instanceof HttpError ? error.message : error.message);
3157
- }
3158
- const payload = {
3159
- resourceId,
3160
- entityId: read.entityId,
3161
- found: read.found,
3162
- ...(read.found ? { fields: read.fields ?? {} } : {}),
3163
- ...(before !== undefined ? { before } : {}),
3164
- observedFields,
3165
- exchange: {
3166
- method: matched.exchange.method,
3167
- path: matched.exchange.path,
3168
- status: matched.exchange.status,
3169
- seq: matched.exchange.seq,
3170
- },
3171
- sessionId: session.sessionId,
3172
- channel: OBSERVE_CHANNEL,
3173
- };
3174
- // The record binds runId/claimId/testId and rides the same ledger
3175
- // attestation MAC as every witnessed record. Contents are
3176
- // witness-produced (proxy capture + adapter read) — `engine-observed`
3177
- // origin, witnessed trust; the suite-driven-browser distinction rides
3178
- // `channel: 'observe'`, which the grader keys off explicitly.
3179
- const issued = issueRecord(state, claimId, OBSERVED_KIND, session.testId, payload, 'engine-observed');
3180
- // Single-use: the matched exchange can never credit another claim.
3181
- const consumed = state.observed.indexOf(matched.exchange);
3182
- if (consumed !== -1)
3183
- state.observed.splice(consumed, 1);
3184
- session.activity += 1;
3185
- return {
3186
- record: { obligationId: claimId, recordId: issued.recordId, operation, entityId: read.entityId },
3187
- };
3188
- }
3189
- /**
3190
- * `POST /witness/server-persistence` — SUPERVISOR ONLY (run token +
3191
- * verifier key; the trusted CLI drain forwards intents the supervised
3192
- * suite could only WRITE to the spool): one persistence claim intent
3193
- * resolved against the app's real state. The WITNESS — never the test
3194
- * process — executes the resource's adapter SERVER PROBE (witness-side,
3195
- * behind the same attestation chain as every adapter read: GF-10
3196
- * loopback + GF-13 fingerprint) and, only on a successful observation,
3197
- * stamps a WITNESSED `persistence.entity` record carrying
3198
- * `channel: 'server'` + `declaredKind: 'server-e2e'`, bound to
3199
- * runId/claimId/testId and covered by the ledger attestation exactly
3200
- * like every witnessed record.
3201
- *
3202
- * Fail-closed resolution (typed causes on the error `detail`):
3203
- * - obligation not registered `server-e2e` → 409, detail
3204
- * `TEST_KIND_UNKNOWN` (declare `kind: server-e2e` in the test map);
3205
- * - replayed/out-of-order intent sequence → 409 (no stale re-drive);
3206
- * - create/update post intent without the paired pre intent → 409
3207
- * (the engine grades before/after; without a witness-side before
3208
- * observation there is nothing to stamp);
3209
- * - missing adapter / missing `probeServer` export / probe throw or
3210
- * malformed probe result → 409 with detail `SERVER_PROBE_UNAVAILABLE`
3211
- * — the intent NEVER resolves to satisfaction on probe trouble.
3212
- *
3213
- * The intent line itself is suite-writable and proves nothing; it only
3214
- * selects WHICH entity the witness probes and what the suite expects.
3215
- * Expectation CONTRADICTION (e.g. create-post but the entity is still
3216
- * absent) is not a probe failure: the record stamps what the witness
3217
- * observed and the verdict engine grades the postcondition — one
3218
- * grading site, engine-owned.
3219
- */
3220
- async function handleServerPersistence(state, res, verifier, body) {
3221
- requireSupervisor(state, verifier);
3222
- if (!isPlainObject(body)) {
3223
- throw new HttpError(400, 'server persistence body must be an object');
3224
- }
3225
- const { resourceId, claimId, operation, phase, intent, key, sequence, testId } = body;
3226
- if (typeof resourceId !== 'string' || resourceId.length === 0) {
3227
- throw new HttpError(400, 'resourceId must be a non-empty string');
3228
- }
3229
- if (typeof claimId !== 'string' || !OBLIGATION_ID_PATTERN.test(claimId)) {
3230
- throw new HttpError(400, "claimId must be an obligation id '<resourceId>:<contract>'");
3231
- }
3232
- if (operation !== 'create' && operation !== 'read' && operation !== 'update' && operation !== 'delete') {
3233
- throw new HttpError(400, "operation must be one of 'create' | 'read' | 'update' | 'delete'");
3234
- }
3235
- // Claim/operation/resource agreement: an intent never steers evidence
3236
- // onto a different obligation identity than the one it names.
3237
- if (claimId !== `${resourceId}:persistence:${operation}`) {
3238
- throw new HttpError(400, `claimId '${claimId}' must equal '<resourceId>:persistence:${operation}' for this intent ` +
3239
- '(claim, resource, and operation must agree — an intent never redirects evidence)');
3240
- }
3241
- if (phase !== 'pre' && phase !== 'post') {
3242
- throw new HttpError(400, "phase must be 'pre' or 'post'");
3243
- }
3244
- if (intent !== 'expect-present' && intent !== 'expect-absent') {
3245
- throw new HttpError(400, "intent must be 'expect-present' or 'expect-absent'");
3246
- }
3247
- if (typeof sequence !== 'number' || !Number.isInteger(sequence) || sequence < 1) {
3248
- throw new HttpError(400, 'sequence must be an integer >= 1');
3249
- }
3250
- if (typeof testId !== 'string' || testId.length === 0) {
3251
- throw new HttpError(400, 'testId must be a non-empty string');
3252
- }
3253
- // The entity key is the probe subject: scalar or column-keyed object,
3254
- // always GF-canonical-JSON-representable (it hashes into the record).
3255
- let entityKey;
3256
- try {
3257
- entityKey = canonicalOf(key);
3258
- }
3259
- catch {
3260
- throw new HttpError(400, 'key must be a JSON scalar or a column-keyed JSON object');
3261
- }
3262
- // Kind gate (trusted mapping layer, never the suite): the witness
3263
- // stamps the server channel ONLY for obligations the supervisor
3264
- // registered as mapping kind 'server-e2e'.
3265
- if (state.serverE2eDeclarations === null ||
3266
- !state.serverE2eDeclarations.has(claimId)) {
3267
- throw new HttpError(409, `server persistence intent refused: obligation '${claimId}' is not registered kind ` +
3268
- `'${SERVER_E2E_TEST_KIND}' on this witness — declare 'kind: ${SERVER_E2E_TEST_KIND}' in the ` +
3269
- 'test-map sidecar and pass the resolved obligations to the supervisor drain ' +
3270
- '(a browser-kind claim is never satisfied through the server channel)', 'TEST_KIND_UNKNOWN');
3271
- }
3272
- // Replay gate: strictly increasing per claimId. A duplicate line (drain
3273
- // restart, spool replay, forged re-append) resolves to a typed failure
3274
- // — an intent is resolved at most once per sequence.
3275
- const lastSequence = state.serverIntentSequences.get(claimId);
3276
- if (lastSequence !== undefined && sequence <= lastSequence) {
3277
- throw new HttpError(409, `server persistence intent refused: sequence ${String(sequence)} for '${claimId}' does not ` +
3278
- `exceed the last accepted (${String(lastSequence)}) — intents are strictly increasing per ` +
3279
- 'claim and a replayed line is never re-driven');
3280
- }
3281
- state.serverIntentSequences.set(claimId, sequence);
3282
- // WITNESS-SIDE probe: the same attestation chain as every adapter read
3283
- // (reviewed adapter, GF-10 loopback, GF-13 fingerprint), then the
3284
- // adapter's own probeServer against the app database. Any trouble here
3285
- // is a typed SERVER_PROBE_UNAVAILABLE failure — never satisfaction.
3286
- const { adapterName, adapter } = await serverProbeContext(state, resourceId, claimId);
3287
- const observation = await runServerProbe(state, adapterName, adapter, resourceId, key);
3288
- if (phase === 'pre') {
3289
- // Pre intents STORE the witness observation; they stamp no record.
3290
- if (operation === 'create') {
3291
- if (intent !== 'expect-absent') {
3292
- throw new HttpError(400, "create pre intents must declare intent 'expect-absent'");
3293
- }
3294
- }
3295
- else if (operation === 'update') {
3296
- if (intent !== 'expect-present') {
3297
- throw new HttpError(400, "update pre intents must declare intent 'expect-present'");
3298
- }
3299
- }
3300
- else {
3301
- throw new HttpError(400, `pre intents apply only to create/update (operation '${operation}' postconditions need no before-state)`);
3302
- }
3303
- const preKey = `${claimId}\u0000${entityKey}`;
3304
- if (state.serverPreObservations.has(preKey)) {
3305
- throw new HttpError(409, `server persistence intent refused: claim '${claimId}' already holds a pending ` +
3306
- 'pre-observation for this entity — advance the sequence and post the mutation first');
3307
- }
3308
- state.serverPreObservations.set(preKey, {
3309
- resourceId,
3310
- kind: operation === 'create' ? 'absence' : 'entity',
3311
- found: observation.found,
3312
- ...(observation.found ? { fields: observation.fields ?? {} } : {}),
3313
- });
3314
- const response = { resolved: 'pre', found: observation.found };
3315
- sendJson(res, 200, response);
3316
- return;
3317
- }
3318
- // Post intents consume the paired pre-observation (create/update) and
3319
- // stamp ONE self-contained witnessed record — the same `before` shapes
3320
- // the browser path's persistence reads carry, so the verdict engine
3321
- // grades BOTH channels with the same postcondition code.
3322
- let before;
3323
- if (operation === 'create' || operation === 'update') {
3324
- const preKey = `${claimId}\u0000${entityKey}`;
3325
- const pre = state.serverPreObservations.get(preKey);
3326
- const wantedKind = operation === 'create' ? 'absence' : 'entity';
3327
- if (pre === undefined || pre.resourceId !== resourceId || pre.kind !== wantedKind) {
3328
- throw new HttpError(409, `server persistence intent refused: no witness-side pre-observation for '${claimId}' on ` +
3329
- `entity ${entityKey} — write the pre intent (before the mutation) so the engine can ` +
3330
- 'grade the before/after postcondition from its OWN observations');
3331
- }
3332
- state.serverPreObservations.delete(preKey);
3333
- before =
3334
- pre.kind === 'absence'
3335
- ? { entityAbsent: !pre.found }
3336
- : pre.found
3337
- ? { found: true, fields: pre.fields }
3338
- : { found: false };
3339
- }
3340
- const payload = {
3341
- resourceId,
3342
- entityId: key,
3343
- found: observation.found,
3344
- ...(observation.found ? { fields: observation.fields ?? {} } : {}),
3345
- ...(before !== undefined ? { before } : {}),
3346
- channel: SERVER_CHANNEL,
3347
- declaredKind: SERVER_E2E_TEST_KIND,
3348
- intent: { phase: 'post', expectation: intent, sequence },
3349
- };
3350
- // The record binds runId/claimId/testId (hashed into its provenance id)
3351
- // and rides the same ledger attestation MAC as every witnessed record.
3352
- const issued = issuePersistenceRecord(state, claimId, testId, payload);
3353
- const response = {
3354
- recordId: issued.recordId,
3355
- runId: issued.runId,
3356
- trust: issued.trust,
3357
- channel: SERVER_CHANNEL,
3358
- verdictRelevant: { found: observation.found },
3359
- };
3360
- sendJson(res, 200, response);
3361
- }
3362
- /**
3363
- * Resolves the reviewed adapter for a server probe, enforcing the FULL
3364
- * browser-path attestation chain (ADR 0001 reviewed adapter, GF-10
3365
- * loopback, GF-13 fingerprint) so a server observation is exactly as
3366
- * trustworthy as an engine-side adapter read. A missing adapter or a
3367
- * missing `probeServer` export resolves typed SERVER_PROBE_UNAVAILABLE
3368
- * (actionable; the intent never resolves to satisfaction).
3369
- */
3370
- async function serverProbeContext(state, resourceId, claimId) {
3371
- let adapterName;
3372
- let adapter;
3373
- try {
3374
- const context = await adapterReadContext(state, resourceId);
3375
- adapterName = context.adapterName;
3376
- adapter = context.adapter;
3377
- }
3378
- catch (error) {
3379
- if (error instanceof HttpError) {
3380
- throw new HttpError(error.status, `server persistence intent for '${claimId}' failed: ${error.message}`, 'SERVER_PROBE_UNAVAILABLE');
3381
- }
3382
- throw error;
3383
- }
3384
- if (typeof adapter.probeServer !== 'function') {
3385
- throw new HttpError(409, `adapter '${adapterName}' for resource '${resourceId}' exports no server probe ` +
3386
- `(add 'async probeServer(ctx, subject) => ({found, fields})' to ` +
3387
- `'.gateforge/adapters/${adapterName}.mjs'); server-witnessed persistence intents ` +
3388
- 'fail closed without one', 'SERVER_PROBE_UNAVAILABLE');
3389
- }
3390
- return { adapterName, adapter };
3391
- }
3392
- /**
3393
- * Executes one adapter server probe (witness process ONLY) and validates
3394
- * the result shape. A throw or a malformed return is a typed
3395
- * SERVER_PROBE_UNAVAILABLE failure; a well-shaped result — even one that
3396
- * contradicts the suite's expectation — is an honest observation the
3397
- * verdict engine grades.
3398
- */
3399
- async function runServerProbe(state, adapterName, adapter, resourceId, key) {
3400
- const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl;
3401
- const ctx = makeAdapterContext(baseUrl ?? '', resourceId, (path) => adapterGet(baseUrl ?? '', state.options.requestTimeoutMs, path, state.options.adapterReadAuthorization), state.options.adapterReadAuthorization
3402
- ? { authorization: state.options.adapterReadAuthorization }
3403
- : undefined);
3404
- let raw;
3405
- try {
3406
- raw = await adapter.probeServer?.(ctx, key);
3407
- }
3408
- catch (error) {
3409
- throw new HttpError(409, `adapter '${adapterName}' server probe failed for resource '${resourceId}': ` +
3410
- `${error instanceof Error ? error.message : String(error)}`, 'SERVER_PROBE_UNAVAILABLE');
3411
- }
3412
- if (!isPlainObject(raw) ||
3413
- typeof raw['found'] !== 'boolean' ||
3414
- !(raw['fields'] === null || raw['fields'] === undefined || isPlainObject(raw['fields']))) {
3415
- throw new HttpError(409, `adapter '${adapterName}' server probe must return {found: boolean, fields: object|null} ` +
3416
- `for resource '${resourceId}' (got ${(() => {
3417
- try {
3418
- return canonicalOf(raw);
3419
- }
3420
- catch {
3421
- return '<non-JSON>';
3422
- }
3423
- })()})`, 'SERVER_PROBE_UNAVAILABLE');
3424
- }
3425
- return {
3426
- found: raw['found'],
3427
- fields: raw['fields'] ?? null,
3428
- };
3429
- }
3430
- /**
3431
- * Resolves the reviewed adapter + mediated read base for one resource,
3432
- * enforcing the full attestation chain (ADR 0001 adapter, GF-10
3433
- * loopback, GF-13 fingerprint). Shared by persistence reads and
3434
- * pre-observations.
3435
- */
3436
- async function adapterReadContext(state, resourceId) {
3437
- const classification = state.classifications[resourceId];
3438
- const adapterName = classification?.evidenceAdapter ?? resourceId;
3439
- const adapter = state.adapters.get(adapterName);
3440
- if (adapter === undefined) {
3441
- throw new HttpError(400, `no reviewed evidence adapter registered for resource '${resourceId}' ` +
3442
- `(looked for '.gateforge/adapters/${adapterName}.mjs'); ` +
3443
- 'user-facing resources cannot be proven without a trusted adapter (ADR 0001)');
3444
- }
3445
- const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl;
3446
- if (baseUrl === null || baseUrl === undefined || baseUrl === '') {
3447
- throw new HttpError(400, `adapter '${adapterName}' has no read base (set GATEFORGE_ADAPTER_BASE_URL, ` +
3448
- 'the adapter baseUrl export, or the attestation target)');
3449
- }
3450
- // GF-10 (per-read mediation): never build a request against a
3451
- // non-loopback base.
3452
- await assertLoopback(baseUrl, `adapter '${adapterName}'`);
3453
- // GF-13 minimal v1 attestation: the adapter target must present the
3454
- // marker the adapter declares, and match the run's attested env.
3455
- const probe = await probeEnvFingerprint(baseUrl, state.options.requestTimeoutMs);
3456
- const mismatch = envFingerprintMismatch(probe, adapter.environmentFingerprint, state.options.targetFingerprint ?? null);
3457
- if (mismatch !== null) {
3458
- throw new HttpError(409, `persistence record for resource '${resourceId}' rejected: ${mismatch}`, mismatch);
3459
- }
3460
- return { adapterName, adapter, baseUrl };
3461
- }
3462
- /**
3463
- * The GET-only transport adapters use. `path` may be absolute
3464
- * (http(s)://…) or relative to the adapter base. Timeout is enforced by
3465
- * aborting the underlying fetch.
3466
- */
3467
- async function adapterGet(baseUrl, timeoutMs, path, readAuthorization) {
3468
- const target = /^https?:\/\//.test(path) ? path : `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
3469
- const controller = new AbortController();
3470
- const timer = setTimeout(() => controller.abort(), timeoutMs);
3471
- try {
3472
- // Pinned egress: attested hostnames connect to their
3473
- // startup-approved loopback IPs (Host preserved for tenant
3474
- // routing); unpinned names behave exactly as before.
3475
- const response = await pinnedGet(target, {
3476
- timeoutMs,
3477
- headers: {
3478
- // Operator-issued read-only service credential for the ENGINE's own
3479
- // adapter reads (see WitnessOptions.adapterReadAuthorization); never
3480
- // forwarded to the suite and never attached to browser traffic.
3481
- ...(readAuthorization ? { authorization: readAuthorization } : {}),
3482
- },
3483
- signal: controller.signal,
3484
- });
3485
- return {
3486
- status: response.status,
3487
- headers: response.headers,
3488
- json: () => response.json(),
3489
- text: () => response.text(),
3490
- };
3491
- }
3492
- finally {
3493
- clearTimeout(timer);
3494
- }
3495
- }
3496
- /**
3497
- * Issues one witness-stamped record into the ledger.
3498
- *
3499
- * Trust follows ORIGIN, not channel (GF-23, audit round 3): the tested
3500
- * suite owns the browser and holds the run token, so a submitted
3501
- * `ui.action`/`ui.visible-result` payload proves only that the suite
3502
- * asserted it — those records are stamped `trust: 'claimed'` with
3503
- * `origin: 'suite-submitted'`. Records whose contents the witness
3504
- * itself observed engine-side (the adapter read behind
3505
- * `persistence.entity`) are stamped `trust: 'witnessed'` with
3506
- * `origin: 'engine-observed'`. Attestation (the ledger MAC) proves the
3507
- * witness issued a record; it can never prove a UI event happened.
3508
- */
3509
- function issueRecord(state, obligationId, kind, testId, payload, origin) {
3510
- const issuedAt = state.nowIso();
3511
- const recordId = recordIdOf({
3512
- runId: state.options.runId,
3513
- obligationId,
3514
- kind,
3515
- testId,
3516
- origin,
3517
- payload,
3518
- });
3519
- const record = {
3520
- schemaVersion: 1,
3521
- recordId,
3522
- runId: state.options.runId,
3523
- trust: origin === 'engine-observed' ? 'witnessed' : 'claimed',
3524
- obligationId,
3525
- kind,
3526
- testId,
3527
- origin,
3528
- payload,
3529
- issuedAt,
3530
- };
3531
- state.ledger.set(recordId, record);
3532
- return record;
3533
- }
3534
- /**
3535
- * Issues a persistence record bound to the claim that requested the
3536
- * adapter read (same testId/obligationId as the claim). The payload is
3537
- * the ENGINE OBSERVATION assembled by the caller — entityId + fields
3538
- * from the ADAPTER RESPONSE (never from caller args), plus the
3539
- * presence/expectation/before data the engine grades postconditions
3540
- * against.
3541
- */
3542
- function issuePersistenceRecord(state, claimId, testId, payload) {
3543
- return issueRecord(state, claimId, PERSISTENCE_KIND, testId, payload, 'engine-observed');
3544
- }
3545
- /** UUID shape for run/invocation identities (validated, never compared across runs). */
3546
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3547
- /** 64-char lowercase hex shape for input digests. */
3548
- const INPUT_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
3549
- /**
3550
- * Binds the trusted run context (plan §11.4): the validated current
3551
- * `{runId, invocationId, inputDigest}` from the trusted CLI/orchestrator
3552
- * is frozen in witness memory. Requires BOTH the run token (outer gate)
3553
- * and the verifier key header — a suite holding only the run token gets
3554
- * 401 and the context is unchanged. Binding is allowed only before any
3555
- * proxy exchange, pre-observation, or evidence issuance, and while no
3556
- * proxy exchange is in flight; a used witness answers 409. Repeating the
3557
- * identical binding is idempotent (200); any change to a bound value is
3558
- * 409 — bound state is never relabeled.
3559
- *
3560
- * Args:
3561
- * state: running witness state.
3562
- * res: response to answer.
3563
- * verifier: the `x-gateforge-verifier` header value.
3564
- * body: parsed request body (must carry runId/invocationId/inputDigest).
3565
- */
3566
- async function handleRunContext(state, res, verifier, body) {
3567
- const verifierKey = state.options.verifierKey;
3568
- if (verifierKey === null || verifierKey === undefined) {
3569
- sendJson(res, 409, { error: 'witness has no verifier key; run-context binding is unavailable' });
3570
- return;
3571
- }
3572
- if (typeof verifier !== 'string' || !timingSafeEqual(verifier, verifierKey)) {
3573
- sendJson(res, 401, { error: 'unauthorized: expected x-gateforge-verifier with the verifier key' });
3574
- return;
3575
- }
3576
- if (!isPlainObject(body)) {
3577
- throw new HttpError(400, 'run-context body must be an object');
3578
- }
3579
- const record = body;
3580
- const runId = record['runId'];
3581
- const invocationId = record['invocationId'];
3582
- const inputDigest = record['inputDigest'];
3583
- if (typeof runId !== 'string' ||
3584
- !UUID_PATTERN.test(runId) ||
3585
- typeof invocationId !== 'string' ||
3586
- !UUID_PATTERN.test(invocationId) ||
3587
- typeof inputDigest !== 'string' ||
3588
- !INPUT_DIGEST_PATTERN.test(inputDigest)) {
3589
- throw new HttpError(400, 'run-context requires runId (UUID), invocationId (UUID), and inputDigest (64-char lowercase hex)');
3590
- }
3591
- if (runId !== state.options.runId) {
3592
- sendJson(res, 409, {
3593
- error: `run-context runId '${runId}' does not match this witness run '${state.options.runId}'; ` +
3594
- 'adopt the witness run id first, then bind — a witness already used by an older ' +
3595
- 'invocation is rejected, start a fresh witness for a new invocation',
3596
- });
3597
- return;
3598
- }
3599
- const existing = state.runContext;
3600
- if (existing !== null) {
3601
- if (existing.runId === runId &&
3602
- existing.invocationId === invocationId &&
3603
- existing.inputDigest === inputDigest) {
3604
- sendJson(res, 200, { bound: true, ...existing });
3605
- return;
3606
- }
3607
- sendJson(res, 409, {
3608
- error: 'run context is already bound and differs; bound state is never relabeled — ' +
3609
- 'start a fresh witness for a new invocation',
3610
- });
3611
- return;
3612
- }
3613
- if (state.ledger.size > 0 ||
3614
- state.observed.length > 0 ||
3615
- state.preObservations.size > 0 ||
3616
- state.serverPreObservations.size > 0 ||
3617
- state.serverIntentSequences.size > 0 ||
3618
- state.sessions.size > 0 ||
3619
- state.proxyInFlight > 0) {
3620
- sendJson(res, 409, {
3621
- error: 'witness already observed or issued evidence (or holds an open test session); ' +
3622
- 'run-context binding is allowed only before any proxy exchange, pre-observation, ' +
3623
- 'server probe, session, or issuance — start a fresh witness for a new invocation',
3624
- });
3625
- return;
3626
- }
3627
- state.runContext = { runId, invocationId, inputDigest };
3628
- state.observedSeqAtBind = state.observedSeq;
3629
- sendJson(res, 200, { bound: true, runId, invocationId, inputDigest });
3630
- }
3631
- /**
3632
- * Serves the authenticated live attestation (pin #7, GF-23, plan §11.3):
3633
- * the SAME v2 signed envelope object the shutdown append writes —
3634
- * `{attestationVersion: 2, runId, invocationId, inputDigest, recordIds,
3635
- * mac}` with the MAC over the domain-tagged body. Requires the verifier
3636
- * key — a secret the tested suite never receives — so only an
3637
- * orchestrator-grade caller (the evaluating CLI) can certify issuance;
3638
- * the suite's run token authorizes submissions, never attestation.
3639
- * Without a configured verifier key the witness answers 409, and an
3640
- * unbound witness answers 409 as well: it must not sign whatever digest
3641
- * a suite-writable manifest happens to carry.
3642
- */
3643
- function handleLedgerAttestation(state, res, verifier) {
3644
- const verifierKey = state.options.verifierKey;
3645
- if (verifierKey === null || verifierKey === undefined) {
3646
- sendJson(res, 409, { error: 'witness has no verifier key; attestation is unavailable' });
3647
- return;
3648
- }
3649
- if (typeof verifier !== 'string' || !timingSafeEqual(verifier, verifierKey)) {
3650
- sendJson(res, 401, { error: 'unauthorized: expected x-gateforge-verifier with the verifier key' });
3651
- return;
3652
- }
3653
- const bound = state.runContext;
3654
- if (bound === null) {
3655
- sendJson(res, 409, {
3656
- error: 'witness has no bound run context; bind POST /run-context before observation — ' +
3657
- 'an unbound witness issues no authenticated attestation',
3658
- });
3659
- return;
3660
- }
3661
- const recordIds = [...state.ledger.keys()].sort(compareStrings);
3662
- sendJson(res, 200, {
3663
- attestationVersion: ATTESTATION_VERSION,
3664
- runId: bound.runId,
3665
- invocationId: bound.invocationId,
3666
- inputDigest: bound.inputDigest,
3667
- recordIds,
3668
- mac: attestationMac(verifierKey, {
3669
- runId: bound.runId,
3670
- invocationId: bound.invocationId,
3671
- inputDigest: bound.inputDigest,
3672
- recordIds,
3673
- }),
3674
- });
3675
- }
3676
- /** Stops the server and appends issued recordIds to the run manifest. */
3677
- async function stopWitness(state) {
3678
- if (state.stopped)
3679
- return;
3680
- state.stopped = true;
3681
- if (state.proxyServer !== null) {
3682
- const proxy = state.proxyServer;
3683
- state.proxyServer = null;
3684
- await new Promise((resolveClose) => {
3685
- proxy.close(() => resolveClose());
3686
- });
3687
- }
3688
- for (const session of state.sessions.values()) {
3689
- await stopSessionProxy(session);
3690
- }
3691
- // Phase 4 lifecycle shutdown: release every live fixture lease namespace.
3692
- // Timeouts/failures release only their own namespace and never flip a
3693
- // verdict — releases here are best-effort shutdown hygiene.
3694
- {
3695
- const provider = state.options.fixtureProvider ?? null;
3696
- if (provider !== null) {
3697
- for (const execution of state.caseExecutions.values()) {
3698
- try {
3699
- await provider.release(execution.lease.leaseId);
3700
- }
3701
- catch {
3702
- // Best-effort: shutdown must complete.
3703
- }
3704
- }
3705
- }
3706
- }
3707
- await state.engineBrowser.closeAll();
3708
- await new Promise((resolveClose) => {
3709
- state.server.close(() => resolveClose());
3710
- });
3711
- appendRecordIdsToManifest(state);
3712
- }
3713
- /**
3714
- * Pin #4/#7, plan §11.3–§11.4: at shutdown, append the issued recordIds
3715
- * to the run manifest in the state dir (sorted, deduplicated; preserves
3716
- * every other field). Absent manifest → no-op (standalone witness).
3717
- *
3718
- * The append NEVER reads a digest from the suite-writable manifest: the
3719
- * v2 `attestation` envelope is built from the FROZEN bound context
3720
- * (bound via authenticated `POST /run-context` before any observation)
3721
- * plus EXACTLY the ids this witness issued — nothing more. The
3722
- * pre-existing `recordIds` in the manifest came from the suite-writable
3723
- * file, so merging them in would let a hostile suite have its forged
3724
- * computed ids signed as issued (audit round 3). They are discarded,
3725
- * not merged. An unbound witness appends the bare ids for reporting
3726
- * only — no attestation, so downstream evaluation fails closed.
3727
- * No legacy `recordIdsMac` is written: v1 MACs never authorize evidence.
3728
- */
3729
- function appendRecordIdsToManifest(state) {
3730
- const stateDir = state.options.stateDir;
3731
- if (stateDir === null || stateDir === undefined || stateDir === '')
3732
- return;
3733
- const manifestPath = join(resolve(process.cwd(), stateDir), 'manifest.json');
3734
- let raw;
3735
- try {
3736
- raw = readFileSync(manifestPath, 'utf8');
3737
- }
3738
- catch {
3739
- return;
3740
- }
3741
- let manifest;
3742
- try {
3743
- manifest = JSON.parse(raw);
3744
- }
3745
- catch {
3746
- return; // malformed manifest: never corrupt it; evaluation reads it leniently
3747
- }
3748
- const issued = [...state.ledger.keys()].sort(compareStrings);
3749
- const updated = { ...manifest, recordIds: issued };
3750
- // Drop any legacy v1 MAC the suite (or an older writer) left behind:
3751
- // it must never authorize evidence, not even when it verifies.
3752
- delete updated['recordIdsMac'];
3753
- const verifierKey = state.options.verifierKey;
3754
- const bound = state.runContext;
3755
- if (verifierKey !== null && verifierKey !== undefined && bound !== null) {
3756
- updated['invocationId'] = bound.invocationId;
3757
- updated['inputDigest'] = bound.inputDigest;
3758
- updated['attestation'] = {
3759
- attestationVersion: ATTESTATION_VERSION,
3760
- runId: bound.runId,
3761
- invocationId: bound.invocationId,
3762
- inputDigest: bound.inputDigest,
3763
- recordIds: issued,
3764
- mac: attestationMac(verifierKey, {
3765
- runId: bound.runId,
3766
- invocationId: bound.invocationId,
3767
- inputDigest: bound.inputDigest,
3768
- recordIds: issued,
3769
- }),
3770
- };
3771
- }
3772
- writeFileSync(manifestPath, `${canonicalOf(updated)}\n`, 'utf8');
3773
36
  }
3774
37
  //# sourceMappingURL=server.js.map