@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.
- package/README.md +31 -0
- package/dist/constants.d.ts +13 -140
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +13 -148
- package/dist/constants.js.map +1 -1
- package/dist/cypress/plugin.d.ts +53 -0
- package/dist/cypress/plugin.d.ts.map +1 -0
- package/dist/cypress/plugin.js +369 -0
- package/dist/cypress/plugin.js.map +1 -0
- package/dist/cypress/spec-scan.d.ts +39 -0
- package/dist/cypress/spec-scan.d.ts.map +1 -0
- package/dist/cypress/spec-scan.js +301 -0
- package/dist/cypress/spec-scan.js.map +1 -0
- package/dist/diagnosis.d.ts +39 -0
- package/dist/diagnosis.d.ts.map +1 -0
- package/dist/diagnosis.js +63 -0
- package/dist/diagnosis.js.map +1 -0
- package/dist/discovery/adapter-projection.d.ts +13 -0
- package/dist/discovery/adapter-projection.d.ts.map +1 -0
- package/dist/discovery/adapter-projection.js +84 -0
- package/dist/discovery/adapter-projection.js.map +1 -0
- package/dist/discovery/config-locations.d.ts +5 -0
- package/dist/discovery/config-locations.d.ts.map +1 -0
- package/dist/discovery/config-locations.js +19 -0
- package/dist/discovery/config-locations.js.map +1 -0
- package/dist/discovery/cypress-runner-adapter.d.ts +112 -0
- package/dist/discovery/cypress-runner-adapter.d.ts.map +1 -0
- package/dist/discovery/cypress-runner-adapter.js +501 -0
- package/dist/discovery/cypress-runner-adapter.js.map +1 -0
- package/dist/discovery/discover.d.ts +53 -1
- package/dist/discovery/discover.d.ts.map +1 -1
- package/dist/discovery/discover.js +100 -3
- package/dist/discovery/discover.js.map +1 -1
- package/dist/discovery/git-ignore.d.ts +13 -0
- package/dist/discovery/git-ignore.d.ts.map +1 -0
- package/dist/discovery/git-ignore.js +62 -0
- package/dist/discovery/git-ignore.js.map +1 -0
- package/dist/discovery/index.d.ts +9 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +9 -0
- package/dist/discovery/index.js.map +1 -1
- package/dist/discovery/playwright-runner-adapter.d.ts +108 -0
- package/dist/discovery/playwright-runner-adapter.d.ts.map +1 -0
- package/dist/discovery/playwright-runner-adapter.js +185 -0
- package/dist/discovery/playwright-runner-adapter.js.map +1 -0
- package/dist/discovery/prepare-barrier.d.ts +522 -0
- package/dist/discovery/prepare-barrier.d.ts.map +1 -0
- package/dist/discovery/prepare-barrier.js +739 -0
- package/dist/discovery/prepare-barrier.js.map +1 -0
- package/dist/discovery/pytest-adapter.d.ts.map +1 -1
- package/dist/discovery/pytest-adapter.js +6 -2
- package/dist/discovery/pytest-adapter.js.map +1 -1
- package/dist/discovery/pytest-runner-adapter.d.ts +124 -0
- package/dist/discovery/pytest-runner-adapter.d.ts.map +1 -0
- package/dist/discovery/pytest-runner-adapter.js +454 -0
- package/dist/discovery/pytest-runner-adapter.js.map +1 -0
- package/dist/discovery/reconcile.d.ts +80 -11
- package/dist/discovery/reconcile.d.ts.map +1 -1
- package/dist/discovery/reconcile.js +395 -78
- package/dist/discovery/reconcile.js.map +1 -1
- package/dist/discovery/runner-env.d.ts +88 -2
- package/dist/discovery/runner-env.d.ts.map +1 -1
- package/dist/discovery/runner-env.js +142 -4
- package/dist/discovery/runner-env.js.map +1 -1
- package/dist/discovery/static-discovery.d.ts +38 -3
- package/dist/discovery/static-discovery.d.ts.map +1 -1
- package/dist/discovery/static-discovery.js +454 -15
- package/dist/discovery/static-discovery.js.map +1 -1
- package/dist/discovery/supervised-run.d.ts +199 -10
- package/dist/discovery/supervised-run.d.ts.map +1 -1
- package/dist/discovery/supervised-run.js +350 -39
- package/dist/discovery/supervised-run.js.map +1 -1
- package/dist/discovery/trusted-config.d.ts +180 -6
- package/dist/discovery/trusted-config.d.ts.map +1 -1
- package/dist/discovery/trusted-config.js +279 -10
- package/dist/discovery/trusted-config.js.map +1 -1
- package/dist/discovery/vitest-runner-adapter.d.ts +172 -0
- package/dist/discovery/vitest-runner-adapter.d.ts.map +1 -0
- package/dist/discovery/vitest-runner-adapter.js +607 -0
- package/dist/discovery/vitest-runner-adapter.js.map +1 -0
- package/dist/fixture/consumer-runner.d.ts +4 -0
- package/dist/fixture/consumer-runner.d.ts.map +1 -0
- package/dist/fixture/consumer-runner.js +38 -0
- package/dist/fixture/consumer-runner.js.map +1 -0
- package/dist/fixture/evidence.d.ts +27 -0
- package/dist/fixture/evidence.d.ts.map +1 -1
- package/dist/fixture/evidence.js +12 -0
- package/dist/fixture/evidence.js.map +1 -1
- package/dist/fixture/fixture.d.ts +2 -1
- package/dist/fixture/fixture.d.ts.map +1 -1
- package/dist/fixture/fixture.js +1 -17
- package/dist/fixture/fixture.js.map +1 -1
- package/dist/fixture/witness-client.d.ts +6 -174
- package/dist/fixture/witness-client.d.ts.map +1 -1
- package/dist/fixture/witness-client.js +6 -267
- package/dist/fixture/witness-client.js.map +1 -1
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/json.d.ts +6 -12
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +6 -17
- package/dist/json.js.map +1 -1
- package/dist/reporter/project-graph-reporter.d.ts +92 -0
- package/dist/reporter/project-graph-reporter.d.ts.map +1 -0
- package/dist/reporter/project-graph-reporter.js +92 -0
- package/dist/reporter/project-graph-reporter.js.map +1 -0
- package/dist/reporter/reporter.cjs.map +1 -1
- package/dist/reporter/reporter.d.ts +93 -4
- package/dist/reporter/reporter.d.ts.map +1 -1
- package/dist/reporter/reporter.js +174 -18
- package/dist/reporter/reporter.js.map +1 -1
- package/dist/runner-claims.d.ts +13 -0
- package/dist/runner-claims.d.ts.map +1 -0
- package/dist/runner-claims.js +46 -0
- package/dist/runner-claims.js.map +1 -0
- package/dist/runner-resolution.d.ts +3 -0
- package/dist/runner-resolution.d.ts.map +1 -0
- package/dist/runner-resolution.js +14 -0
- package/dist/runner-resolution.js.map +1 -0
- package/dist/supervisor/client.d.ts +23 -1
- package/dist/supervisor/client.d.ts.map +1 -1
- package/dist/supervisor/client.js +49 -0
- package/dist/supervisor/client.js.map +1 -1
- package/dist/supervisor/drain.d.ts +65 -0
- package/dist/supervisor/drain.d.ts.map +1 -1
- package/dist/supervisor/drain.js +353 -9
- package/dist/supervisor/drain.js.map +1 -1
- package/dist/supervisor/index.d.ts +7 -3
- package/dist/supervisor/index.d.ts.map +1 -1
- package/dist/supervisor/index.js +7 -3
- package/dist/supervisor/index.js.map +1 -1
- package/dist/supervisor/spool.d.ts +65 -2
- package/dist/supervisor/spool.d.ts.map +1 -1
- package/dist/supervisor/spool.js +47 -0
- package/dist/supervisor/spool.js.map +1 -1
- package/dist/surface.d.ts +6 -207
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +6 -264
- package/dist/surface.js.map +1 -1
- package/dist/vitest/index.d.ts +23 -0
- package/dist/vitest/index.d.ts.map +1 -0
- package/dist/vitest/index.js +356 -0
- package/dist/vitest/index.js.map +1 -0
- package/dist/vitest/reporter.d.ts +83 -0
- package/dist/vitest/reporter.d.ts.map +1 -0
- package/dist/vitest/reporter.js +211 -0
- package/dist/vitest/reporter.js.map +1 -0
- package/dist/vitest/worker-slot.d.ts +36 -0
- package/dist/vitest/worker-slot.d.ts.map +1 -0
- package/dist/vitest/worker-slot.js +51 -0
- package/dist/vitest/worker-slot.js.map +1 -0
- package/dist/witness/adapter-registry.d.ts +6 -50
- package/dist/witness/adapter-registry.d.ts.map +1 -1
- package/dist/witness/adapter-registry.js +6 -257
- package/dist/witness/adapter-registry.js.map +1 -1
- package/dist/witness/behavior-request.d.ts +6 -76
- package/dist/witness/behavior-request.d.ts.map +1 -1
- package/dist/witness/behavior-request.js +6 -273
- package/dist/witness/behavior-request.js.map +1 -1
- package/dist/witness/behavior.d.ts +6 -48
- package/dist/witness/behavior.d.ts.map +1 -1
- package/dist/witness/behavior.js +6 -114
- package/dist/witness/behavior.js.map +1 -1
- package/dist/witness/bin.d.ts +6 -17
- package/dist/witness/bin.d.ts.map +1 -1
- package/dist/witness/bin.js +6 -168
- package/dist/witness/bin.js.map +1 -1
- package/dist/witness/browser.d.ts +14 -206
- package/dist/witness/browser.d.ts.map +1 -1
- package/dist/witness/browser.js +15 -654
- package/dist/witness/browser.js.map +1 -1
- package/dist/witness/classifications.d.ts +6 -38
- package/dist/witness/classifications.d.ts.map +1 -1
- package/dist/witness/classifications.js +6 -83
- package/dist/witness/classifications.js.map +1 -1
- package/dist/witness/env-attestation.d.ts +6 -94
- package/dist/witness/env-attestation.d.ts.map +1 -1
- package/dist/witness/env-attestation.js +6 -198
- package/dist/witness/env-attestation.js.map +1 -1
- package/dist/witness/fixture-provider.d.ts +6 -82
- package/dist/witness/fixture-provider.d.ts.map +1 -1
- package/dist/witness/fixture-provider.js +6 -105
- package/dist/witness/fixture-provider.js.map +1 -1
- package/dist/witness/loopback-pins.d.ts +6 -75
- package/dist/witness/loopback-pins.d.ts.map +1 -1
- package/dist/witness/loopback-pins.js +6 -240
- package/dist/witness/loopback-pins.js.map +1 -1
- package/dist/witness/server.d.ts +8 -26
- package/dist/witness/server.d.ts.map +1 -1
- package/dist/witness/server.js +24 -3761
- package/dist/witness/server.js.map +1 -1
- package/dist/witness/types.d.ts +6 -882
- package/dist/witness/types.d.ts.map +1 -1
- package/dist/witness/types.js +9 -1
- package/dist/witness/types.js.map +1 -1
- package/examples/overlay-proof.spec.js +75 -0
- package/package.json +8 -2
- package/python/gateforge_pytest_plugin.py +462 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GLOBAL native preparation freeze barrier (contract: "ONE native
|
|
3
|
+
* process, ONE GLOBAL prepared snapshot").
|
|
4
|
+
*
|
|
5
|
+
* Why this exists. A Playwright config's prerequisite (`setup`) projects
|
|
6
|
+
* produce their artifacts — the saved `playwright/.auth/user.json` of the
|
|
7
|
+
* standard auth pattern above all — WHILE the behavioral projects are
|
|
8
|
+
* already scheduled to run. Gateforge's candidate freeze, on the other
|
|
9
|
+
* hand, is taken BEFORE the runner starts, so a run whose setup writes
|
|
10
|
+
* generated bytes into the workspace always ends with a candidate tree
|
|
11
|
+
* that no longer equals the frozen one, and no receipt is ever sealed.
|
|
12
|
+
* That is the honest failure this module removes — not by excluding the
|
|
13
|
+
* generated bytes (that would weaken every binding), but by taking ONE
|
|
14
|
+
* prepared snapshot AFTER the whole prerequisite stage and BEFORE any
|
|
15
|
+
* body project, and then demanding the workspace stay exactly that.
|
|
16
|
+
*
|
|
17
|
+
* The shape of the barrier, and why each part exists:
|
|
18
|
+
*
|
|
19
|
+
* - ONE controller project. The engine adds a project of its own that
|
|
20
|
+
* depends on the FULL upstream prerequisite closure of the planned
|
|
21
|
+
* graph, and gives every non-prerequisite planned project that
|
|
22
|
+
* controller as its FIRST dependency (its own captured edges follow,
|
|
23
|
+
* unchanged, in their emitted order). Prerequisites keep their edges
|
|
24
|
+
* exactly as captured. The runner's own phase scheduler then orders
|
|
25
|
+
* prerequisite → controller → body inside ONE process, with the
|
|
26
|
+
* runner's real dependency semantics — not a second process, not a flag.
|
|
27
|
+
* - The controller is a real Playwright test, because the environment
|
|
28
|
+
* leak it has to undo happens in WORKERS: the native scheduler unions
|
|
29
|
+
* every dependency worker's produced environment into its dependents
|
|
30
|
+
* (`runner/tasks.js`), so merely emitting an empty controller project
|
|
31
|
+
* would hand each body the whole preparation environment.
|
|
32
|
+
* - A REQUEST/RELEASE handshake. The controller writes a request naming
|
|
33
|
+
* the exact run, invocation and per-invocation nonce; the trusted CLI
|
|
34
|
+
* — the only process holding supervisor credentials — validates every
|
|
35
|
+
* prerequisite identity against the authenticated witness execution
|
|
36
|
+
* trace, freezes, and answers with a SIGNED release. A request that
|
|
37
|
+
* never comes, an unsigned release, a release for another
|
|
38
|
+
* run/invocation/nonce, and a release whose bound controller spec
|
|
39
|
+
* bytes no longer hash to what the CLI pinned are all refusals, and a
|
|
40
|
+
* controller that never receives a valid release fails its own bounded
|
|
41
|
+
* wait instead of letting the bodies run unfrozen.
|
|
42
|
+
* - An ephemeral Ed25519 signature. A release is a file, and a file at
|
|
43
|
+
* this boundary is forgeable by any code running as the same user. The
|
|
44
|
+
* private key is minted per invocation and never leaves the CLI
|
|
45
|
+
* process — never a child environment, never a file; the controller
|
|
46
|
+
* receives only the public key. Same-UID modify/restore or in-process
|
|
47
|
+
* tampering stays OUTSIDE this boundary (this is not a filesystem
|
|
48
|
+
* sandbox); what the signature closes is an ordinary control-file
|
|
49
|
+
* forgery and any replay of an older or different release.
|
|
50
|
+
*
|
|
51
|
+
* Honesty boundary (repeated on purpose): this is a CONTROL barrier, not
|
|
52
|
+
* a physical one. It orders one native process, proves the prerequisites
|
|
53
|
+
* really passed under supervision, and proves the workspace did not move
|
|
54
|
+
* between the freeze and the seal. It does not make the source read-only.
|
|
55
|
+
*/
|
|
56
|
+
import { createHash, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
57
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
|
|
58
|
+
import { tmpdir } from 'node:os';
|
|
59
|
+
import { join } from 'node:path';
|
|
60
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
|
+
/** The engine-owned controller project name (never a candidate project). */
|
|
62
|
+
export const FREEZE_CONTROLLER_PROJECT = 'gateforge-freeze';
|
|
63
|
+
/**
|
|
64
|
+
* Directory (inside the excluded run-state dir) holding the barrier's
|
|
65
|
+
* handshake DOCUMENTS — the controller's request, the CLI's signed
|
|
66
|
+
* release and its refusal document. They are evidence about the run,
|
|
67
|
+
* not test-shaped files, so they stay where the CLI pins and seals
|
|
68
|
+
* them.
|
|
69
|
+
*
|
|
70
|
+
* The generated controller SPEC is deliberately NOT here: a `.mjs` spec
|
|
71
|
+
* file inside the repository is a file a consumer's own Playwright
|
|
72
|
+
* configuration can collect (see {@link FREEZE_CONTROL_SPEC_FILE}).
|
|
73
|
+
*/
|
|
74
|
+
export const FREEZE_CONTROL_DIR = 'native-freeze';
|
|
75
|
+
/**
|
|
76
|
+
* The generated controller spec: the ONE worker that performs the handshake.
|
|
77
|
+
*
|
|
78
|
+
* Plain ESM JavaScript under an `.mjs` name, deliberately. The runner
|
|
79
|
+
* decides a file's module kind from its extension first and only then from
|
|
80
|
+
* the nearest `package.json` `type`, and a consumer repository may sit
|
|
81
|
+
* under EITHER: a CommonJS/default root would have this spec `require`d,
|
|
82
|
+
* which turns its ESM imports into `require()` of a `file:` URL and fails
|
|
83
|
+
* to load at all. `.mjs` fixes the module kind on the file itself, so the
|
|
84
|
+
* controller's imports resolve as real ESM imports whatever the
|
|
85
|
+
* consumer's root package says — no manifest inspection, no install
|
|
86
|
+
* restriction, and no candidate module ever loaded by the CLI. The
|
|
87
|
+
* consequence is that the generated bytes must stay plain JavaScript:
|
|
88
|
+
* nothing TypeScript-only may reappear in the template below.
|
|
89
|
+
*
|
|
90
|
+
* WHERE the file is written follows from what it IS: a parseable test
|
|
91
|
+
* file. Inside the repository it is a candidate the consumer's own
|
|
92
|
+
* runner collects — a config at the repo root with no restricting
|
|
93
|
+
* `testDir` enumerates every parseable file below it, so an engine spec
|
|
94
|
+
* parked in the run-state subtree was still enumerated as a consumer
|
|
95
|
+
* case, by the engine's own `--list` and by the owner's `npx playwright
|
|
96
|
+
* test` alike. It is therefore written into a private per-run directory
|
|
97
|
+
* OUTSIDE the repository ({@link FreezeControl.specDir}) and removed
|
|
98
|
+
* once the run no longer reads it.
|
|
99
|
+
*/
|
|
100
|
+
export const FREEZE_CONTROL_SPEC_FILE = 'freeze-controller.spec.mjs';
|
|
101
|
+
/** The controller's request document (written by the controller worker). */
|
|
102
|
+
export const FREEZE_REQUEST_FILE = 'freeze-request.json';
|
|
103
|
+
/** The CLI's signed release document (read by the controller worker). */
|
|
104
|
+
export const FREEZE_RELEASE_FILE = 'freeze-release.json';
|
|
105
|
+
/**
|
|
106
|
+
* The trusted CLI's REFUSAL document (read by the controller worker).
|
|
107
|
+
*
|
|
108
|
+
* The handshake needs a failure channel as well as a success one, or a
|
|
109
|
+
* permanent refusal turns into a bounded-but-slow timeout: the trusted
|
|
110
|
+
* side answers "your prerequisite sealed as failed", and the controller
|
|
111
|
+
* should stop waiting the moment it hears that, not poll to its own long
|
|
112
|
+
* bound. This file is FAILURE ONLY. Nothing here can ever unblock a body
|
|
113
|
+
* project — only a correctly signed `FREEZE_RELEASE_FILE` does — so a
|
|
114
|
+
* controller that trusts this document can only ever fail earlier, never
|
|
115
|
+
* proceed. A forged refusal therefore costs a run its barrier, which the
|
|
116
|
+
* signing already guarantees; a missing one costs nothing.
|
|
117
|
+
*/
|
|
118
|
+
export const FREEZE_REFUSAL_FILE = 'freeze-refusal.json';
|
|
119
|
+
/**
|
|
120
|
+
* Environment names the runner itself owns in every worker process, and
|
|
121
|
+
* which the projection must therefore never touch.
|
|
122
|
+
*
|
|
123
|
+
* The reason is structural, read from the runner itself:
|
|
124
|
+
* `worker/workerMain.js` assigns `TEST_WORKER_INDEX` and
|
|
125
|
+
* `TEST_PARALLEL_INDEX` AFTER the process booted, and
|
|
126
|
+
* `runner/workerHost.js` spreads `FORCE_COLOR` and `DEBUG_COLORS` into
|
|
127
|
+
* every worker's environment at spawn. `lib/common/process.js` computes
|
|
128
|
+
* the produced-environment diff against the worker's OWN boot
|
|
129
|
+
* environment, so all four names appear in a produced diff without any
|
|
130
|
+
* prerequisite having written them — a subtraction reset that touched
|
|
131
|
+
* them would report a deletion the bodies then inherit.
|
|
132
|
+
*
|
|
133
|
+
* The reset set is otherwise derived BY SUBTRACTION — every key in the
|
|
134
|
+
* controller's environment or in the trusted baseline, minus these four.
|
|
135
|
+
* That is equivalent to a positively-owned namespace ONLY under the
|
|
136
|
+
* invariant below, and the invariant is the real risk: nothing in the
|
|
137
|
+
* run pins it, so a future change that starts injecting environment into
|
|
138
|
+
* workers beyond these names would be silently projected away rather
|
|
139
|
+
* than reported.
|
|
140
|
+
*
|
|
141
|
+
* Gateforge injects nothing into worker environment beyond the runner
|
|
142
|
+
* child's own baseline: `buildRunnerChildEnv` refuses run-state paths
|
|
143
|
+
* and signing material, and the freeze control reaches the controller
|
|
144
|
+
* inlined in its generated spec rather than through the environment.
|
|
145
|
+
* Under that invariant the worker's environment is the baseline, plus
|
|
146
|
+
* preparation-written keys, plus these four names — so the reset set is
|
|
147
|
+
* exactly the baseline keys a prerequisite overrode (restored) and the
|
|
148
|
+
* keys a prerequisite introduced (deleted), whatever they are named.
|
|
149
|
+
*/
|
|
150
|
+
export const FREEZE_ENV_EXEMPT = [
|
|
151
|
+
'TEST_WORKER_INDEX',
|
|
152
|
+
'TEST_PARALLEL_INDEX',
|
|
153
|
+
'FORCE_COLOR',
|
|
154
|
+
'DEBUG_COLORS',
|
|
155
|
+
];
|
|
156
|
+
/**
|
|
157
|
+
* Deterministic canonical JSON: object keys sorted, no insignificant
|
|
158
|
+
* whitespace. The CLI signs exactly these bytes and the controller
|
|
159
|
+
* verifies exactly these bytes, so neither side may depend on a property
|
|
160
|
+
* order or a serializer default.
|
|
161
|
+
*
|
|
162
|
+
* @param value: any JSON-compatible value.
|
|
163
|
+
*
|
|
164
|
+
* @returns
|
|
165
|
+
* string: the canonical form.
|
|
166
|
+
*/
|
|
167
|
+
export function canonicalFreezeJson(value) {
|
|
168
|
+
if (value === null || typeof value !== 'object')
|
|
169
|
+
return JSON.stringify(value) ?? 'null';
|
|
170
|
+
if (Array.isArray(value))
|
|
171
|
+
return `[${value.map((entry) => canonicalFreezeJson(entry)).join(',')}]`;
|
|
172
|
+
const record = value;
|
|
173
|
+
return `{${Object.keys(record)
|
|
174
|
+
.sort()
|
|
175
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalFreezeJson(record[key])}`)
|
|
176
|
+
.join(',')}}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Mints one ephemeral Ed25519 key pair for the release signature. The
|
|
180
|
+
* private key is returned to the CLI and NEVER written anywhere: not the
|
|
181
|
+
* trusted config, not the run state, not a child environment.
|
|
182
|
+
*
|
|
183
|
+
* @returns
|
|
184
|
+
* FreezeSigningKeyPair: the pair, with the public key already
|
|
185
|
+
* base64-encoded as raw SPKI.
|
|
186
|
+
*/
|
|
187
|
+
export function mintFreezeSigningKeyPair() {
|
|
188
|
+
const pair = generateKeyPairSync('ed25519');
|
|
189
|
+
const spki = pair.publicKey.export({ format: 'der', type: 'spki' });
|
|
190
|
+
return {
|
|
191
|
+
publicKey: pair.publicKey,
|
|
192
|
+
publicKeyBase64: spki.toString('base64'),
|
|
193
|
+
privateKey: pair.privateKey,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Signs one release payload with the invocation's ephemeral private key.
|
|
198
|
+
*
|
|
199
|
+
* @param privateKey: the ephemeral key (CLI memory only).
|
|
200
|
+
* @param payload: the payload to sign.
|
|
201
|
+
*
|
|
202
|
+
* @returns
|
|
203
|
+
* FreezeReleaseDocument: the document the controller verifies.
|
|
204
|
+
*/
|
|
205
|
+
export function signFreezeRelease(privateKey, payload) {
|
|
206
|
+
const signature = sign(null, Buffer.from(canonicalFreezeJson(payload), 'utf8'), privateKey);
|
|
207
|
+
return { schemaVersion: 1, payload, signature: signature.toString('base64') };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Verifies one release document against the pinned public key and the
|
|
211
|
+
* exact identities the controller itself was armed with.
|
|
212
|
+
*
|
|
213
|
+
* A release passes only when ALL of these hold: the document shape, the
|
|
214
|
+
* Ed25519 signature under the pinned key, the controller project name, the
|
|
215
|
+
* run, the invocation, the per-invocation nonce, and the sha256 of the
|
|
216
|
+
* controller's OWN current spec bytes. The last check is what makes a
|
|
217
|
+
* replaced control file detectable rather than trusted.
|
|
218
|
+
*
|
|
219
|
+
* @param document: the parsed release document.
|
|
220
|
+
* @param publicKeyBase64: the pinned base64 raw SPKI public key.
|
|
221
|
+
* @param expected: the identities and spec digest the controller was armed with.
|
|
222
|
+
*
|
|
223
|
+
* @returns
|
|
224
|
+
* {ok: true, payload} | {ok: false, reason}: the verified payload, or the
|
|
225
|
+
* single plain reason the release was refused.
|
|
226
|
+
*/
|
|
227
|
+
export function verifyFreezeRelease(document, publicKeyBase64, expected) {
|
|
228
|
+
if (typeof document !== 'object' || document === null) {
|
|
229
|
+
return { ok: false, reason: 'the freeze release is not an object' };
|
|
230
|
+
}
|
|
231
|
+
const row = document;
|
|
232
|
+
if (row['schemaVersion'] !== 1 || typeof row['signature'] !== 'string') {
|
|
233
|
+
return { ok: false, reason: 'the freeze release is missing its signature' };
|
|
234
|
+
}
|
|
235
|
+
const payload = row['payload'];
|
|
236
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
237
|
+
return { ok: false, reason: 'the freeze release carries no payload' };
|
|
238
|
+
}
|
|
239
|
+
const fields = payload;
|
|
240
|
+
if (fields['schemaVersion'] !== 1) {
|
|
241
|
+
return { ok: false, reason: 'the freeze release payload has an unknown schema version' };
|
|
242
|
+
}
|
|
243
|
+
for (const key of ['project', 'runId', 'invocationId', 'nonce', 'preparedTreeId', 'specDigest', 'sealedAt']) {
|
|
244
|
+
if (typeof fields[key] !== 'string' || fields[key].length === 0) {
|
|
245
|
+
return { ok: false, reason: `the freeze release payload has no ${key}` };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
let signatureOk = false;
|
|
249
|
+
try {
|
|
250
|
+
signatureOk = verify(null, Buffer.from(canonicalFreezeJson(fields), 'utf8'), createPublicKey({ key: Buffer.from(publicKeyBase64, 'base64'), format: 'der', type: 'spki' }), Buffer.from(row['signature'], 'base64'));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
signatureOk = false;
|
|
254
|
+
}
|
|
255
|
+
if (!signatureOk) {
|
|
256
|
+
return { ok: false, reason: 'the freeze release is unsigned or signed by another key' };
|
|
257
|
+
}
|
|
258
|
+
if (fields['project'] !== expected.project) {
|
|
259
|
+
return { ok: false, reason: 'the freeze release names another controller project' };
|
|
260
|
+
}
|
|
261
|
+
if (fields['runId'] !== expected.runId || fields['invocationId'] !== expected.invocationId) {
|
|
262
|
+
return { ok: false, reason: 'the freeze release belongs to another run or invocation' };
|
|
263
|
+
}
|
|
264
|
+
if (fields['nonce'] !== expected.nonce) {
|
|
265
|
+
return { ok: false, reason: 'the freeze release was minted for another nonce (a replayed release)' };
|
|
266
|
+
}
|
|
267
|
+
if (fields['specDigest'] !== expected.specDigest) {
|
|
268
|
+
return { ok: false, reason: 'the controller control spec no longer matches the bytes the run pinned' };
|
|
269
|
+
}
|
|
270
|
+
return { ok: true, payload: fields };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Projects one worker environment back to the trusted baseline the runner
|
|
274
|
+
* child started with.
|
|
275
|
+
*
|
|
276
|
+
* The native scheduler unions every dependency worker's produced
|
|
277
|
+
* environment into its dependents, so a body worker inherits whatever a
|
|
278
|
+
* prerequisite worker assigned — including a deleted key, which the
|
|
279
|
+
* scheduler carries as `undefined` and Node then simply omits from the
|
|
280
|
+
* child. This restores exactly that difference: every name the trusted
|
|
281
|
+
* baseline OWNS goes back to its own value, and every name the baseline
|
|
282
|
+
* never owned is removed. The keys Playwright's own worker bootstrap
|
|
283
|
+
* writes ({@link FREEZE_ENV_EXEMPT}) are left alone — they are the
|
|
284
|
+
* runner's, not a prerequisite's.
|
|
285
|
+
*
|
|
286
|
+
* Both sides are read by OWN membership, never through an inherited
|
|
287
|
+
* lookup, because the names at stake are ordinary environment names the
|
|
288
|
+
* runner itself spawns workers with: `constructor`, `toString` and
|
|
289
|
+
* `__proto__` are each a property every plain object inherits, so a
|
|
290
|
+
* baseline read as `base[key]` reports those as owned values of `Object`,
|
|
291
|
+
* `Object.prototype.toString` and `Object.prototype` itself and hands
|
|
292
|
+
* the body worker that instead of deleting what a prerequisite
|
|
293
|
+
* introduced. No prefix restriction avoids that, and none is acceptable.
|
|
294
|
+
*
|
|
295
|
+
* The restore side is therefore a `defineProperty`, not an assignment:
|
|
296
|
+
* on an ordinary object an assignment to `__proto__` does not create an
|
|
297
|
+
* own key at all — the inherited accessor swallows it — so a baseline
|
|
298
|
+
* name a prerequisite deleted could never be put back.
|
|
299
|
+
*
|
|
300
|
+
* @param current: the controller worker's own environment (mutated in place).
|
|
301
|
+
* @param base: the trusted runner child's baseline environment.
|
|
302
|
+
* @param exempt: names the projection must not touch.
|
|
303
|
+
*
|
|
304
|
+
* @returns
|
|
305
|
+
* BaselineEnvProjection: what the projection changed, sorted, for the
|
|
306
|
+
* controller's own diagnostic.
|
|
307
|
+
*/
|
|
308
|
+
export function projectBaselineEnv(current, base, exempt = FREEZE_ENV_EXEMPT) {
|
|
309
|
+
const skip = new Set(exempt);
|
|
310
|
+
const restored = [];
|
|
311
|
+
const deleted = [];
|
|
312
|
+
// One value descriptor for the whole projection, allocated on the first
|
|
313
|
+
// restoration and reused: every name needs the same configurable,
|
|
314
|
+
// writable, enumerable shape, and one descriptor avoids a per-key
|
|
315
|
+
// allocation in the one worker that runs on this path.
|
|
316
|
+
let descriptor;
|
|
317
|
+
for (const key of Object.keys(base)) {
|
|
318
|
+
if (skip.has(key))
|
|
319
|
+
continue;
|
|
320
|
+
const want = base[key];
|
|
321
|
+
if (Object.hasOwn(current, key) && current[key] === want)
|
|
322
|
+
continue;
|
|
323
|
+
if (descriptor === undefined) {
|
|
324
|
+
descriptor = { value: want, writable: true, enumerable: true, configurable: true };
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
descriptor.value = want;
|
|
328
|
+
}
|
|
329
|
+
Object.defineProperty(current, key, descriptor);
|
|
330
|
+
restored.push(key);
|
|
331
|
+
}
|
|
332
|
+
// `Object.keys` is own-only, so a name the baseline never owned is an
|
|
333
|
+
// OWN entry a prerequisite introduced, whatever its value: an own entry
|
|
334
|
+
// the scheduler carries as `undefined` is still an entry no baseline
|
|
335
|
+
// ever had, and the worker must not keep it. A name that is only
|
|
336
|
+
// inherited is not an entry and is never reached here.
|
|
337
|
+
for (const key of Object.keys(current)) {
|
|
338
|
+
if (skip.has(key) || Object.hasOwn(base, key))
|
|
339
|
+
continue;
|
|
340
|
+
delete current[key];
|
|
341
|
+
deleted.push(key);
|
|
342
|
+
}
|
|
343
|
+
restored.sort();
|
|
344
|
+
deleted.sort();
|
|
345
|
+
return { restored, deleted };
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Resolves the engine barrier module the generated controller imports.
|
|
349
|
+
* Absolute and pack-relative for the same reason the trusted reporter
|
|
350
|
+
* entry is: a generated worker spec must load an ENGINE file by absolute
|
|
351
|
+
* path, never a candidate-relative one. Resolves from both the `src/` and
|
|
352
|
+
* the `dist/` layout.
|
|
353
|
+
*
|
|
354
|
+
* @param fromModule: module URL to resolve the pack from (default: this file).
|
|
355
|
+
*
|
|
356
|
+
* @returns
|
|
357
|
+
* string: absolute `<pack>/dist|src/discovery/prepare-barrier.js` path.
|
|
358
|
+
*/
|
|
359
|
+
export function barrierModulePath(fromModule = import.meta.url) {
|
|
360
|
+
return fileURLToPath(new URL('./prepare-barrier.js', fromModule));
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The controller worker's own bytes.
|
|
364
|
+
*
|
|
365
|
+
* The generated spec imports the ENGINE barrier module by absolute path so
|
|
366
|
+
* the canonical payload form, the signature verification and the
|
|
367
|
+
* environment projection are ONE implementation on both sides — never a
|
|
368
|
+
* second copy that can drift from the one the CLI signed.
|
|
369
|
+
*
|
|
370
|
+
* The armed control reaches the worker as a JSON document bound through
|
|
371
|
+
* `JSON.parse`, never as an object literal inlined in these bytes: the
|
|
372
|
+
* baseline is a map of ordinary environment NAMES, so a name like
|
|
373
|
+
* `__proto__` has to arrive as an own key, and a value has to arrive
|
|
374
|
+
* escaped. `JSON.parse` is the one binding that gives both — it creates
|
|
375
|
+
* data properties — while a literal would be re-read by the JavaScript
|
|
376
|
+
* parser: a backtick or a `${` in a value would end the literal, and
|
|
377
|
+
* `__proto__:` in a literal is a PROTOTYPE assignment, not a key.
|
|
378
|
+
*
|
|
379
|
+
* @param control: the armed control (paths and pinned identities).
|
|
380
|
+
*
|
|
381
|
+
* @returns
|
|
382
|
+
* string: the generated plain-ESM JavaScript source of the controller spec.
|
|
383
|
+
*/
|
|
384
|
+
export function freezeControllerSpecSource(control) {
|
|
385
|
+
const armed = {
|
|
386
|
+
project: control.project,
|
|
387
|
+
runId: control.runId,
|
|
388
|
+
invocationId: control.invocationId,
|
|
389
|
+
nonce: control.nonce,
|
|
390
|
+
requestPath: control.requestPath,
|
|
391
|
+
refusalPath: control.refusalPath,
|
|
392
|
+
releasePath: control.releasePath,
|
|
393
|
+
specPath: control.specPath,
|
|
394
|
+
releasePublicKey: control.releasePublicKey,
|
|
395
|
+
timeoutMs: control.timeoutMs,
|
|
396
|
+
baseEnv: control.baseEnv,
|
|
397
|
+
exemptEnv: control.exemptEnv,
|
|
398
|
+
};
|
|
399
|
+
return `// GENERATED by gateforge trusted supervision — the GLOBAL native
|
|
400
|
+
// preparation freeze controller. Never edit: the CLI pinned these exact
|
|
401
|
+
// bytes before the run and the signed release binds their sha256.
|
|
402
|
+
//
|
|
403
|
+
// Plain ESM JavaScript, loaded as an ES module by the runner's own
|
|
404
|
+
// extension rule. The imports below are therefore real ESM imports and
|
|
405
|
+
// must never be written as require() calls or as TypeScript-only syntax.
|
|
406
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
407
|
+
import { createHash } from 'node:crypto';
|
|
408
|
+
import nativeRunner from ${JSON.stringify(pathToFileURL(control.testModulePath).href)};
|
|
409
|
+
import {
|
|
410
|
+
canonicalFreezeJson,
|
|
411
|
+
projectBaselineEnv,
|
|
412
|
+
verifyFreezeRelease,
|
|
413
|
+
} from ${JSON.stringify(control.barrierModulePath)};
|
|
414
|
+
|
|
415
|
+
// The runner's own playwright/test module is CommonJS, and it assembles
|
|
416
|
+
// its exports at RUN time, so Node's static named-export detection finds
|
|
417
|
+
// none of them and a named import cannot LOAD the file at all. The
|
|
418
|
+
// ordinary ESM binding for a CommonJS module is its default export,
|
|
419
|
+
// which is that module object itself: the runner and its assertions as
|
|
420
|
+
// members. It is the same file, from the same install, that every body
|
|
421
|
+
// in this run executes against — no second module, no other version.
|
|
422
|
+
const { test, expect } = nativeRunner;
|
|
423
|
+
|
|
424
|
+
const ARMED = JSON.parse(${JSON.stringify(JSON.stringify(armed))});
|
|
425
|
+
|
|
426
|
+
test('gateforge global preparation freeze', async () => {
|
|
427
|
+
test.setTimeout(ARMED.timeoutMs + 60000);
|
|
428
|
+
writeFileSync(
|
|
429
|
+
ARMED.requestPath,
|
|
430
|
+
canonicalFreezeJson({
|
|
431
|
+
schemaVersion: 1,
|
|
432
|
+
project: ARMED.project,
|
|
433
|
+
runId: ARMED.runId,
|
|
434
|
+
invocationId: ARMED.invocationId,
|
|
435
|
+
nonce: ARMED.nonce,
|
|
436
|
+
requestedAt: new Date().toISOString(),
|
|
437
|
+
}) + '\\n',
|
|
438
|
+
'utf8',
|
|
439
|
+
);
|
|
440
|
+
// Wait for EITHER outcome. A refusal is failure-only: it can end this
|
|
441
|
+
// wait early but never unblock it, so a permanent refusal (a
|
|
442
|
+
// prerequisite that sealed failed, skipped, retried or duplicated)
|
|
443
|
+
// fails the controller within the poll interval instead of running out
|
|
444
|
+
// the whole bound. A forged refusal costs the run its barrier, which
|
|
445
|
+
// the signed release already guarantees; a missing one costs nothing.
|
|
446
|
+
await expect
|
|
447
|
+
.poll(
|
|
448
|
+
() => (existsSync(ARMED.releasePath) ? 'released' : existsSync(ARMED.refusalPath) ? 'refused' : 'waiting'),
|
|
449
|
+
{ timeout: ARMED.timeoutMs, intervals: [25] },
|
|
450
|
+
)
|
|
451
|
+
.not.toBe('waiting');
|
|
452
|
+
// A release document that EXISTS is always read and verified, even when
|
|
453
|
+
// a refusal document sits beside it. The signature is the only thing
|
|
454
|
+
// that can unblock a body, so a present release must produce its OWN
|
|
455
|
+
// verdict — an unsigned document, one signed by another key and a
|
|
456
|
+
// replayed document from an earlier invocation each have a distinct,
|
|
457
|
+
// precise reason, and consulting the refusal first would mask all three
|
|
458
|
+
// behind the refusal's wording. The refusal is read only when there is
|
|
459
|
+
// no release at all, which is the ordinary refusal path.
|
|
460
|
+
if (existsSync(ARMED.releasePath)) {
|
|
461
|
+
let release = null;
|
|
462
|
+
try {
|
|
463
|
+
release = JSON.parse(readFileSync(ARMED.releasePath, 'utf8'));
|
|
464
|
+
} catch (error) {
|
|
465
|
+
expect(false, 'the freeze release is not readable JSON: ' + String(error)).toBe(true);
|
|
466
|
+
}
|
|
467
|
+
const verified = verifyFreezeRelease(release, ARMED.releasePublicKey, {
|
|
468
|
+
project: ARMED.project,
|
|
469
|
+
runId: ARMED.runId,
|
|
470
|
+
invocationId: ARMED.invocationId,
|
|
471
|
+
nonce: ARMED.nonce,
|
|
472
|
+
specDigest: createHash('sha256').update(readFileSync(ARMED.specPath)).digest('hex'),
|
|
473
|
+
});
|
|
474
|
+
expect(verified.ok, verified.ok ? '' : verified.reason).toBe(true);
|
|
475
|
+
} else {
|
|
476
|
+
const refusal = JSON.parse(readFileSync(ARMED.refusalPath, 'utf8'));
|
|
477
|
+
throw new Error(
|
|
478
|
+
'the trusted side refused the prepared candidate: ' +
|
|
479
|
+
(typeof refusal.reason === 'string' ? refusal.reason : 'no reason given'),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
// The environment projection is the whole reason this worker exists:
|
|
483
|
+
// the native scheduler unions every dependency worker's produced
|
|
484
|
+
// environment into its dependents, so a prerequisite's assignment
|
|
485
|
+
// would otherwise reach every body. Assert the RESULT, not that the
|
|
486
|
+
// projection ran — after it, every non-exempt name must carry exactly
|
|
487
|
+
// the baseline value and nothing the baseline never had may remain.
|
|
488
|
+
// OWN membership and OWN value on both sides: a name only one side owns
|
|
489
|
+
// is a difference whatever an inherited lookup would return for it, and
|
|
490
|
+
// an inherited property is never a baseline value.
|
|
491
|
+
projectBaselineEnv(process.env, ARMED.baseEnv, ARMED.exemptEnv);
|
|
492
|
+
const exempt = new Set(ARMED.exemptEnv);
|
|
493
|
+
const drifted = [...new Set([...Object.keys(process.env), ...Object.keys(ARMED.baseEnv)])]
|
|
494
|
+
.filter((key) => !exempt.has(key))
|
|
495
|
+
.filter(
|
|
496
|
+
(key) =>
|
|
497
|
+
Object.hasOwn(process.env, key) !== Object.hasOwn(ARMED.baseEnv, key) ||
|
|
498
|
+
process.env[key] !== ARMED.baseEnv[key],
|
|
499
|
+
)
|
|
500
|
+
.sort();
|
|
501
|
+
expect(drifted, 'the worker environment still differs from the trusted baseline: ' + drifted.join(', ')).toEqual([]);
|
|
502
|
+
});
|
|
503
|
+
`;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Writes the controller spec and returns the armed barrier plus the sha256
|
|
507
|
+
* of the exact bytes written. The spec is GENERATED engine code and the
|
|
508
|
+
* only worker that performs the handshake, and its bytes are pinned in
|
|
509
|
+
* CLI memory for the whole run; because it is a parseable test file it is
|
|
510
|
+
* written OUTSIDE the repository, into a private per-run directory
|
|
511
|
+
* ({@link FreezeControl.specDir}), while the handshake documents stay in
|
|
512
|
+
* the excluded run-state subtree.
|
|
513
|
+
*
|
|
514
|
+
* @param input: the identities, paths, public key and baseline environment.
|
|
515
|
+
*
|
|
516
|
+
* @returns
|
|
517
|
+
* ArmedFreezeControl: the control (with absolute paths) and its spec digest.
|
|
518
|
+
*/
|
|
519
|
+
export function armFreezeControl(input) {
|
|
520
|
+
const controlDir = join(input.stateDir, FREEZE_CONTROL_DIR);
|
|
521
|
+
mkdirSync(controlDir, { recursive: true, mode: 0o700 });
|
|
522
|
+
// Owner-only. The control directory holds the pinned public key, the
|
|
523
|
+
// controller's request, the CLI's signed release and the refusal
|
|
524
|
+
// document; nothing else on this host has a reason to read them, and
|
|
525
|
+
// the default 0o755/0o644 would publish the baseline allowlisted
|
|
526
|
+
// variables the generated spec embeds. The private signing key is
|
|
527
|
+
// NEVER written here — it stays in the CLI process's memory.
|
|
528
|
+
chmodSync(controlDir, 0o700);
|
|
529
|
+
// The spec is a TEST file, so it cannot live inside the repository: a
|
|
530
|
+
// consumer configuration whose `testDir` covers the repo root collects
|
|
531
|
+
// every parseable file below it, and would enumerate the engine's own
|
|
532
|
+
// controller as a candidate case — through the engine's `--list` as
|
|
533
|
+
// well as through the owner's own `npx playwright test`. A fresh
|
|
534
|
+
// `mkdtemp` directory under the host temporary directory keeps it out
|
|
535
|
+
// of the candidate entirely and gives every invocation its own, so
|
|
536
|
+
// one run can never read another's spec.
|
|
537
|
+
//
|
|
538
|
+
// Resolved through `realpath` because a temporary directory is
|
|
539
|
+
// commonly a symlink (`/tmp` → `/private/tmp`): the runner reports
|
|
540
|
+
// the spec under the `testDir` it was handed, and both the reporter's
|
|
541
|
+
// exclusion and the CLI's own digest checks compare resolved paths.
|
|
542
|
+
const specDir = realpathSync(mkdtempSync(join(tmpdir(), 'gateforge-freeze-')));
|
|
543
|
+
// `mkdtempSync` already creates the directory 0700; the mode is
|
|
544
|
+
// asserted rather than assumed, so the guarantee does not rest on a
|
|
545
|
+
// default this module does not control.
|
|
546
|
+
chmodSync(specDir, 0o700);
|
|
547
|
+
const control = {
|
|
548
|
+
project: FREEZE_CONTROLLER_PROJECT,
|
|
549
|
+
runId: input.runId,
|
|
550
|
+
invocationId: input.invocationId,
|
|
551
|
+
nonce: input.nonce,
|
|
552
|
+
controlDir,
|
|
553
|
+
specDir,
|
|
554
|
+
specPath: join(specDir, FREEZE_CONTROL_SPEC_FILE),
|
|
555
|
+
requestPath: join(controlDir, FREEZE_REQUEST_FILE),
|
|
556
|
+
releasePath: join(controlDir, FREEZE_RELEASE_FILE),
|
|
557
|
+
refusalPath: join(controlDir, FREEZE_REFUSAL_FILE),
|
|
558
|
+
testModulePath: input.testModulePath,
|
|
559
|
+
barrierModulePath: barrierModulePath(),
|
|
560
|
+
releasePublicKey: input.releasePublicKey,
|
|
561
|
+
timeoutMs: input.timeoutMs,
|
|
562
|
+
baseEnv: { ...input.baseEnv },
|
|
563
|
+
exemptEnv: [...FREEZE_ENV_EXEMPT],
|
|
564
|
+
};
|
|
565
|
+
// A previous invocation's handshake must not be visible to this one: a
|
|
566
|
+
// stale request would be served before this run's controller ever asks,
|
|
567
|
+
// and a stale release or refusal would be read by a controller that
|
|
568
|
+
// never asked. The spec has no such history — it was just generated
|
|
569
|
+
// into a directory this invocation created.
|
|
570
|
+
for (const path of [control.requestPath, control.releasePath, control.refusalPath]) {
|
|
571
|
+
rmSync(path, { force: true });
|
|
572
|
+
}
|
|
573
|
+
const source = freezeControllerSpecSource(control);
|
|
574
|
+
try {
|
|
575
|
+
writeFileSync(control.specPath, source, { encoding: 'utf8', mode: 0o600 });
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
// An armed barrier that never got its spec is not a barrier: leave
|
|
579
|
+
// no directory behind for a run that is about to fail anyway.
|
|
580
|
+
rmSync(specDir, { recursive: true, force: true });
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
control,
|
|
585
|
+
specDigest: createHash('sha256').update(source).digest('hex'),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Removes the private per-run directory holding the generated controller
|
|
590
|
+
* spec, and with it the generated engine file itself.
|
|
591
|
+
*
|
|
592
|
+
* The CLI calls this once the spec has been read for the LAST time — the
|
|
593
|
+
* final pinned-digest check — and on every path that never reaches that
|
|
594
|
+
* check, so no generated spec outlives the run that produced it and no
|
|
595
|
+
* consumer enumeration can ever see one. Idempotent: a second call, or a
|
|
596
|
+
* call for a directory something else already removed, removes nothing.
|
|
597
|
+
*
|
|
598
|
+
* @param control: the armed control.
|
|
599
|
+
*/
|
|
600
|
+
export function removeFreezeSpecDir(control) {
|
|
601
|
+
rmSync(control.specDir, { recursive: true, force: true });
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Writes the trusted side's REFUSAL document for one handshake. It is
|
|
605
|
+
* failure-only by construction: no code path in the controller treats
|
|
606
|
+
* its presence as permission to continue, so a refusal can only shorten a
|
|
607
|
+
* wait, never satisfy one.
|
|
608
|
+
*
|
|
609
|
+
* @param control: the armed control.
|
|
610
|
+
* @param reason: the plain refusal text the controller surfaces.
|
|
611
|
+
*/
|
|
612
|
+
export function writeFreezeRefusal(control, reason) {
|
|
613
|
+
writeFileSync(control.refusalPath, `${canonicalFreezeJson({
|
|
614
|
+
schemaVersion: 1,
|
|
615
|
+
project: control.project,
|
|
616
|
+
runId: control.runId,
|
|
617
|
+
invocationId: control.invocationId,
|
|
618
|
+
nonce: control.nonce,
|
|
619
|
+
reason,
|
|
620
|
+
})}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Reads the controller's request document, or null when it is absent or
|
|
624
|
+
* unusable. An absent request is ordinary (the controller has not asked
|
|
625
|
+
* yet); a MALFORMED one is a refusal the trusted side reports.
|
|
626
|
+
*
|
|
627
|
+
* @param path: absolute request document path.
|
|
628
|
+
*
|
|
629
|
+
* @returns
|
|
630
|
+
* FreezeRequest | null: the parsed request, or null.
|
|
631
|
+
*/
|
|
632
|
+
export function readFreezeRequest(path) {
|
|
633
|
+
let raw;
|
|
634
|
+
try {
|
|
635
|
+
raw = readFileSync(path, 'utf8');
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
let parsed;
|
|
641
|
+
try {
|
|
642
|
+
parsed = JSON.parse(raw);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
648
|
+
return null;
|
|
649
|
+
const row = parsed;
|
|
650
|
+
if (row['schemaVersion'] !== 1)
|
|
651
|
+
return null;
|
|
652
|
+
// Every identity is NARROWED, not asserted: a document the controller
|
|
653
|
+
// wrote (or anything else sharing its writable path) is untrusted input,
|
|
654
|
+
// so a missing or non-string identity makes the whole request unusable
|
|
655
|
+
// rather than a field of `unknown` slipping into the frozen handshake.
|
|
656
|
+
const identity = (key) => {
|
|
657
|
+
const value = row[key];
|
|
658
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
659
|
+
};
|
|
660
|
+
const project = identity('project');
|
|
661
|
+
const runId = identity('runId');
|
|
662
|
+
const invocationId = identity('invocationId');
|
|
663
|
+
const nonce = identity('nonce');
|
|
664
|
+
if (project === null || runId === null || invocationId === null || nonce === null)
|
|
665
|
+
return null;
|
|
666
|
+
return {
|
|
667
|
+
schemaVersion: 1,
|
|
668
|
+
project,
|
|
669
|
+
runId,
|
|
670
|
+
invocationId,
|
|
671
|
+
nonce,
|
|
672
|
+
requestedAt: typeof row['requestedAt'] === 'string' ? row['requestedAt'] : '',
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Rewrites the planned project scopes into the freeze-aware shape the
|
|
677
|
+
* synthesized config emits.
|
|
678
|
+
*
|
|
679
|
+
* The rules, in the order they matter:
|
|
680
|
+
*
|
|
681
|
+
* - The engine's controller project is appended as one more scope. It
|
|
682
|
+
collects its generated spec from its OWN `testDir` — the private
|
|
683
|
+
per-run directory outside the repository the spec was written into,
|
|
684
|
+
which the consumer's own configuration can never collect — and
|
|
685
|
+
depends on the FULL upstream prerequisite closure the trusted CLI
|
|
686
|
+
classified, so the runner's own scheduler runs every prerequisite,
|
|
687
|
+
then the controller, then any body.
|
|
688
|
+
* - A PREREQUISITE keeps its captured edges exactly as they were. Its
|
|
689
|
+
rows are the native cases that must execute genuinely and exactly
|
|
690
|
+
once, and re-ordering them would change what the run proves.
|
|
691
|
+
* - Every other project gets the controller as its FIRST dependency,
|
|
692
|
+
followed by its own captured edges in their original emitted order.
|
|
693
|
+
Placement is not an ordering statement — the runner's phase scheduler
|
|
694
|
+
is topological (`runner/tasks.js`: a project joins the earliest phase
|
|
695
|
+
in which every dependency is already processed), so a body runs after
|
|
696
|
+
the controller either way. Placement IS an environment-merge
|
|
697
|
+
statement: a project's extra environment is the union over
|
|
698
|
+
`project.deps` in emitted order (`tasks.js:341-347`), later entries
|
|
699
|
+
winning, so where the controller lands decides what a body starts
|
|
700
|
+
with. FIRST is the contract's placement, emitted explicitly rather
|
|
701
|
+
than by inserting into a sorted array (a sort would move the
|
|
702
|
+
controller behind any prerequisite that sorts before it);
|
|
703
|
+
`trusted-config.ts` re-asserts it when it emits the final list. Whether
|
|
704
|
+
FIRST or LAST is what the environment projection actually needs is
|
|
705
|
+
under live measurement by the native environment probe; this is the
|
|
706
|
+
contract's ordering until that measurement says otherwise.
|
|
707
|
+
* @param control: the armed controller (its project name, dir and spec).
|
|
708
|
+
* @param scopes: the planned project scopes, unchanged in every other way.
|
|
709
|
+
* @param prerequisiteProjects: the CLI's upstream-closure classification.
|
|
710
|
+
*
|
|
711
|
+
* @returns
|
|
712
|
+
* ProjectScope[]: the scopes to synthesize — the planned scopes in
|
|
713
|
+
* place, plus the controller appended.
|
|
714
|
+
*/
|
|
715
|
+
export function freezeProjectScopes(control, scopes, prerequisiteProjects) {
|
|
716
|
+
const defined = new Set(scopes.map((scope) => scope.name));
|
|
717
|
+
const prerequisites = new Set(prerequisiteProjects.filter((name) => defined.has(name) && name !== control.project));
|
|
718
|
+
return [
|
|
719
|
+
...scopes.map((scope) => prerequisites.has(scope.name) || scope.name === control.project
|
|
720
|
+
? scope
|
|
721
|
+
: {
|
|
722
|
+
...scope,
|
|
723
|
+
dependencies: [
|
|
724
|
+
control.project,
|
|
725
|
+
...(scope.dependencies ?? []).filter((name) => name !== control.project),
|
|
726
|
+
],
|
|
727
|
+
}),
|
|
728
|
+
{
|
|
729
|
+
name: control.project,
|
|
730
|
+
files: [FREEZE_CONTROL_SPEC_FILE],
|
|
731
|
+
// The controller collects from the private directory the spec was
|
|
732
|
+
// written into — never from the state directory, which no longer
|
|
733
|
+
// holds it, and never from the repository root.
|
|
734
|
+
testDir: control.specDir,
|
|
735
|
+
dependencies: [...prerequisites].sort(),
|
|
736
|
+
},
|
|
737
|
+
];
|
|
738
|
+
}
|
|
739
|
+
//# sourceMappingURL=prepare-barrier.js.map
|