@boardwalk-labs/runner 0.1.11 → 0.1.12
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/binding.gyp +12 -0
- package/dist/runtime/agent/budget.d.ts +5 -5
- package/dist/runtime/agent/budget.js +8 -8
- package/dist/runtime/agent/model_rates.js +4 -5
- package/dist/runtime/agent/secret_redactor.js +1 -1
- package/dist/runtime/cancel_watcher.js +1 -1
- package/dist/runtime/credit_watcher.d.ts +1 -1
- package/dist/runtime/credit_watcher.js +5 -5
- package/dist/runtime/freeze_coordinator.d.ts +6 -1
- package/dist/runtime/freeze_coordinator.js +18 -4
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +62 -41
- package/dist/runtime/leaf_executor.d.ts +2 -2
- package/dist/runtime/program_runner.d.ts +1 -1
- package/dist/runtime/program_runner.js +1 -1
- package/dist/runtime/program_worker.d.ts +3 -3
- package/dist/runtime/program_worker.js +7 -7
- package/dist/runtime/recording_secret_resolver.js +1 -1
- package/dist/runtime/run_abort.js +3 -3
- package/dist/runtime/runner_control_client.d.ts +29 -4
- package/dist/runtime/runner_control_client.js +76 -32
- package/dist/runtime/runtime_flusher.d.ts +1 -1
- package/dist/runtime/runtime_flusher.js +3 -3
- package/dist/runtime/support/index.js +5 -6
- package/dist/runtime/tools/artifacts.js +1 -1
- package/dist/runtime/tools/types.d.ts +5 -5
- package/dist/runtime/tools/types.js +7 -7
- package/dist/runtime/tools/web_search.js +5 -6
- package/dist/runtime/uniqueness_reseed.d.ts +7 -0
- package/dist/runtime/uniqueness_reseed.js +65 -0
- package/dist/runtime/wire/artifact_storage.js +2 -2
- package/dist/runtime/wire/inference_proxy.d.ts +1 -1
- package/dist/runtime/wire/inference_proxy.js +2 -2
- package/dist/runtime/workflow_host.d.ts +32 -0
- package/dist/runtime/workflow_host.js +69 -9
- package/native/reseed.c +52 -0
- package/package.json +12 -5
- package/prebuilds/linux-x64/@boardwalk-labs+runner.node +0 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reseed the userspace CSPRNG at a restore boundary (fresh-run identity injection AND every wake —
|
|
3
|
+
* clause 3), before any run code draws randomness. Idempotent and side-effect-free beyond the
|
|
4
|
+
* reseed; safe to call repeatedly. Returns whether the reseed ran (false = addon unavailable, the
|
|
5
|
+
* degraded no-op path). Never throws.
|
|
6
|
+
*/
|
|
7
|
+
export declare function reseedUserspaceCsprng(): boolean;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Snapshot-uniqueness reseed — clause 3 of the SNAPSHOT_UNIQUENESS_CONTRACT, the one sliver of
|
|
2
|
+
// the determinism contract that survives the journal's deletion.
|
|
3
|
+
//
|
|
4
|
+
// A memory snapshot freezes OpenSSL's DRBG, so two clones of one base snapshot hand every run
|
|
5
|
+
// byte-identical `crypto.*` output (proven, contract). The kernel CSPRNG diverges across
|
|
6
|
+
// clones (VMGenID) but that reseed never reaches OpenSSL, and a pure-JS monkeypatch was proven
|
|
7
|
+
// insufficient (named ESM imports of `node:crypto` bypass it — measured 2026-07-09). So the robust
|
|
8
|
+
// fix is a native step that reseeds OpenSSL's DRBG UNDERNEATH every caller: the `bw_reseed` addon
|
|
9
|
+
// (native/reseed.c) chains EVP_RAND_reseed over the primary/public/private DRBGs from the
|
|
10
|
+
// (VMGenID-diverged) OS entropy.
|
|
11
|
+
//
|
|
12
|
+
// This module is the platform-owned JS entry point. It loads the prebuilt addon opportunistically
|
|
13
|
+
// and NO-OPS with a warning when it is unavailable (a platform with no prebuild — e.g. the ARM64
|
|
14
|
+
// Fargate worker, where there is no snapshot and thus nothing to reseed; or a self-host on an
|
|
15
|
+
// unsupported arch). It never throws: a reseed that cannot run must not fail a run.
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { createLogger } from "./support/index.js";
|
|
20
|
+
const log = createLogger("uniqueness_reseed");
|
|
21
|
+
/** undefined = not yet attempted; null = unavailable (no prebuild for this platform). */
|
|
22
|
+
let addon;
|
|
23
|
+
function loadAddon() {
|
|
24
|
+
if (addon !== undefined)
|
|
25
|
+
return addon;
|
|
26
|
+
try {
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
// node-gyp-build resolves prebuilds/<platform>-<arch>/*.node (production) or build/Release
|
|
29
|
+
// (a local dev build), throwing when neither exists for this platform.
|
|
30
|
+
const nodeGypBuild = require("node-gyp-build");
|
|
31
|
+
// dist/runtime/uniqueness_reseed.js → the package root two levels up (holds prebuilds/ + build/).
|
|
32
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
33
|
+
addon = nodeGypBuild(pkgRoot);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
addon = null;
|
|
37
|
+
log.warn("uniqueness_reseed_addon_unavailable", {
|
|
38
|
+
error: err instanceof Error ? err.message : String(err),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return addon;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reseed the userspace CSPRNG at a restore boundary (fresh-run identity injection AND every wake —
|
|
45
|
+
* clause 3), before any run code draws randomness. Idempotent and side-effect-free beyond the
|
|
46
|
+
* reseed; safe to call repeatedly. Returns whether the reseed ran (false = addon unavailable, the
|
|
47
|
+
* degraded no-op path). Never throws.
|
|
48
|
+
*/
|
|
49
|
+
export function reseedUserspaceCsprng() {
|
|
50
|
+
const a = loadAddon();
|
|
51
|
+
if (a === null)
|
|
52
|
+
return false;
|
|
53
|
+
try {
|
|
54
|
+
const ok = a.reseed();
|
|
55
|
+
if (!ok)
|
|
56
|
+
log.warn("uniqueness_reseed_partial", {});
|
|
57
|
+
return ok;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
log.error("uniqueness_reseed_failed", {
|
|
61
|
+
error: err instanceof Error ? err.message : String(err),
|
|
62
|
+
});
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Artifact storage policy — the server-side rules for turning an agent's artifact-write request into
|
|
2
|
-
// a stored S3 object
|
|
2
|
+
// a stored S3 object. These used to live in the `artifacts` TOOL,
|
|
3
3
|
// which runs on the UNTRUSTED runner; under the Runner Credential Broker (the Runner Credential Broker model) the
|
|
4
4
|
// broker owns them so a malicious runner can't bypass content-type neutralization or escape its
|
|
5
5
|
// run's key prefix. Pure functions — unit-tested exhaustively.
|
|
@@ -10,7 +10,7 @@ import * as nodePath from "node:path";
|
|
|
10
10
|
export function workspaceS3Key(orgId, workflowId) {
|
|
11
11
|
return `orgs/${orgId}/workflows/${workflowId}/workspace.tar.gz`;
|
|
12
12
|
}
|
|
13
|
-
// ---- run-artifact retention tag (
|
|
13
|
+
// ---- run-artifact retention tag (S3 lifecycle safety net) ----
|
|
14
14
|
//
|
|
15
15
|
// Run artifacts share the `orgs/{org}/...` prefix with workspace snapshots (`.../workflows/...`) and
|
|
16
16
|
// program artifacts (`.../program-artifacts/...`), and S3 lifecycle prefix filters can't express
|
|
@@ -57,7 +57,7 @@ export declare function parseInferenceRequest(body: string): InferenceProxyReque
|
|
|
57
57
|
/** Serialize one streamed text delta as a single NDJSON line (trailing "\n" included). */
|
|
58
58
|
export declare function serializeDeltaFrame(text: string): string;
|
|
59
59
|
/** Serialize the single terminal turn result as one NDJSON line. `costMicros` is the turn's EXACT
|
|
60
|
-
* upstream cost (
|
|
60
|
+
* upstream cost (the managed provider's per-request cost × 1e6) on the managed lane — 0 for BYO or when unavailable.
|
|
61
61
|
* The worker feeds it to the budget guardrail so `max_usd` tracks real spend, not a token estimate. */
|
|
62
62
|
export declare function serializeResultFrame(turn: ChatTurn, modelRef: string, costMicros?: number): string;
|
|
63
63
|
/** Serialize a terminal error as a single NDJSON line. */
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Transport: the response is **newline-delimited JSON** (one frame per line). A model turn streams
|
|
15
15
|
// many text deltas; NDJSON lets the runner consume them incrementally over the raw socket (the
|
|
16
|
-
// buffered REST path can't stream
|
|
16
|
+
// buffered REST path can't stream). Each frame is one of:
|
|
17
17
|
// { "t": "delta", "text": <string> } — a streamed assistant-text chunk
|
|
18
18
|
// { "t": "result", "turn": <ChatTurn>, "modelRef": <string>, "costMicros": <number> } — terminal turn
|
|
19
19
|
// { "t": "error", "error": { code, message } } — a terminal model/broker error
|
|
@@ -77,7 +77,7 @@ export function serializeDeltaFrame(text) {
|
|
|
77
77
|
return `${JSON.stringify({ t: "delta", text })}\n`;
|
|
78
78
|
}
|
|
79
79
|
/** Serialize the single terminal turn result as one NDJSON line. `costMicros` is the turn's EXACT
|
|
80
|
-
* upstream cost (
|
|
80
|
+
* upstream cost (the managed provider's per-request cost × 1e6) on the managed lane — 0 for BYO or when unavailable.
|
|
81
81
|
* The worker feeds it to the budget guardrail so `max_usd` tracks real spend, not a token estimate. */
|
|
82
82
|
export function serializeResultFrame(turn, modelRef, costMicros = 0) {
|
|
83
83
|
return `${JSON.stringify({ t: "result", turn, modelRef, costMicros })}\n`;
|
|
@@ -52,6 +52,13 @@ export interface ChildDispatcher {
|
|
|
52
52
|
export interface SecretAccessor {
|
|
53
53
|
get(name: string): Promise<string>;
|
|
54
54
|
}
|
|
55
|
+
/** Register-without-release: register a HELD HITL gate so it is
|
|
56
|
+
* answerable while the run keeps running, and poll for the answer. Backed by the broker's
|
|
57
|
+
* `inputs` endpoints. Absent ⇒ a held gate is only answerable once it freezes. */
|
|
58
|
+
export interface HeldInputPort {
|
|
59
|
+
register(seq: number, gate: SuspendSignal["humanInput"]): Promise<unknown>;
|
|
60
|
+
poll(seq: number): Promise<Record<string, unknown>>;
|
|
61
|
+
}
|
|
55
62
|
/** Holds the process for `ms` milliseconds. The seam exists so tests don't wait on real time. An
|
|
56
63
|
* abort fires the hold REJECT (with the signal's RunAbortedError) and clears the timer — so a
|
|
57
64
|
* multi-day sleep aborted early doesn't leave a live timer pinning the event loop open. */
|
|
@@ -135,6 +142,11 @@ export interface WorkerWorkflowHostDeps {
|
|
|
135
142
|
* stream, and work arriving while a freeze is pending queues until the wake.
|
|
136
143
|
*/
|
|
137
144
|
freeze?: FreezeCoordinator;
|
|
145
|
+
/** Register-without-release for HELD human-input gates. Only
|
|
146
|
+
* meaningful alongside `freeze`; absent ⇒ a held gate is answerable only once it freezes. */
|
|
147
|
+
heldInput?: HeldInputPort;
|
|
148
|
+
/** Poll interval for a held gate's answer (default 3s). */
|
|
149
|
+
heldPollIntervalMs?: number;
|
|
138
150
|
/**
|
|
139
151
|
* The highest journaled seq at claim (0 on a fresh run). While re-running seams up to this
|
|
140
152
|
* frontier on a resume, the host is REPLAYING and observability (phase markers, program logs) is
|
|
@@ -147,6 +159,7 @@ export declare class WorkerWorkflowHost implements WorkflowHost {
|
|
|
147
159
|
private readonly sleeper;
|
|
148
160
|
private readonly now;
|
|
149
161
|
private readonly maxSleepMs;
|
|
162
|
+
private readonly heldPollIntervalMs;
|
|
150
163
|
/** Synchronous durable-seam counter + silent-replay live flag (the durable-suspension design). */
|
|
151
164
|
private readonly seq;
|
|
152
165
|
/** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
|
|
@@ -167,6 +180,25 @@ export declare class WorkerWorkflowHost implements WorkflowHost {
|
|
|
167
180
|
/** A suspending seam's freeze wait: step out of the "work" count around the park (the wait itself
|
|
168
181
|
* is what the gate waits FOR, not work that blocks it), then rejoin on resume. */
|
|
169
182
|
private freezeWait;
|
|
183
|
+
/**
|
|
184
|
+
* Snapshot-substrate `humanInput()` with REGISTER-WITHOUT-RELEASE.
|
|
185
|
+
* A gate reached while a sibling seam is still in flight HOLDS (the quiescence gate won't freeze
|
|
186
|
+
* yet), but a human must still be able to answer during that hold. So: register the gate with the
|
|
187
|
+
* broker immediately (it surfaces in the inbox/API at once), then race two outcomes —
|
|
188
|
+
* - the answer arrives (brokered poll) while holding ⇒ WITHDRAW the freeze wait and resolve
|
|
189
|
+
* in-process (the run never froze); or
|
|
190
|
+
* - quiescence is reached with no answer ⇒ the wait freezes, and the wake carries the answer.
|
|
191
|
+
* Once frozen the poll is frozen too (same process), so the race is only live during the hold.
|
|
192
|
+
* A register/poll failure degrades to the plain freeze wait — the gate still works, just without
|
|
193
|
+
* the answerable-while-held property.
|
|
194
|
+
*/
|
|
195
|
+
private freezeHumanInput;
|
|
196
|
+
/** Poll the broker for a held gate's answer until it arrives or the wait withdraws. Resolves with
|
|
197
|
+
* the answer value; never rejects the run (a transient poll error just retries next tick). Runs
|
|
198
|
+
* OUTSIDE the quiescence gate (it is not run work — it must not block a freeze). */
|
|
199
|
+
private pollHeldAnswer;
|
|
200
|
+
/** Map a froze/aborted freeze outcome to the gate's answer (or throw on an unexpected abort). */
|
|
201
|
+
private resolveFreezeAnswer;
|
|
170
202
|
/** The wake's answer for one gate key. A wake whose value is missing the parked gate means the
|
|
171
203
|
* control plane and the snapshot disagree about what this run was waiting for — a platform bug,
|
|
172
204
|
* failed loudly, never a retry. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// WorkerWorkflowHost — the real WorkflowHost the worker installs onto @boardwalk-labs/workflow
|
|
2
2
|
// before running a program (the workflow runtime design). The program's hooks delegate here:
|
|
3
3
|
//
|
|
4
|
-
// agent(prompt, opts) → an ephemeral
|
|
4
|
+
// agent(prompt, opts) → an ephemeral agent leaf (the demoted agent loop)
|
|
5
5
|
// sleep(arg) → an IN-PROCESS hold (hold-and-pay; no checkpoint, no exit)
|
|
6
6
|
// workflows.call(slug, in) → a durable child run (parent holds while it runs)
|
|
7
7
|
// secrets.get(name) → the run's fail-closed secret resolver
|
|
@@ -67,6 +67,7 @@ export class WorkerWorkflowHost {
|
|
|
67
67
|
sleeper;
|
|
68
68
|
now;
|
|
69
69
|
maxSleepMs;
|
|
70
|
+
heldPollIntervalMs;
|
|
70
71
|
/** Synchronous durable-seam counter + silent-replay live flag (the durable-suspension design). */
|
|
71
72
|
seq;
|
|
72
73
|
/** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
|
|
@@ -76,6 +77,7 @@ export class WorkerWorkflowHost {
|
|
|
76
77
|
this.sleeper = deps.sleeper ?? new TimerSleepController();
|
|
77
78
|
this.now = deps.now ?? Date.now;
|
|
78
79
|
this.maxSleepMs = deps.maxSleepMs ?? MAX_SLEEP_MS;
|
|
80
|
+
this.heldPollIntervalMs = deps.heldPollIntervalMs ?? 3_000;
|
|
79
81
|
this.seq = new SeamSequencer(deps.replayFrontier ?? 0);
|
|
80
82
|
this.runtime = deps.runtime;
|
|
81
83
|
}
|
|
@@ -118,15 +120,78 @@ export class WorkerWorkflowHost {
|
|
|
118
120
|
}
|
|
119
121
|
/** A suspending seam's freeze wait: step out of the "work" count around the park (the wait itself
|
|
120
122
|
* is what the gate waits FOR, not work that blocks it), then rejoin on resume. */
|
|
121
|
-
async freezeWait(freeze, signal) {
|
|
123
|
+
async freezeWait(freeze, signal, abort) {
|
|
122
124
|
freeze.endWork();
|
|
123
125
|
try {
|
|
124
|
-
return await freeze.suspendingWait(signal);
|
|
126
|
+
return await freeze.suspendingWait(signal, abort);
|
|
125
127
|
}
|
|
126
128
|
finally {
|
|
127
129
|
freeze.beginWork();
|
|
128
130
|
}
|
|
129
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Snapshot-substrate `humanInput()` with REGISTER-WITHOUT-RELEASE.
|
|
134
|
+
* A gate reached while a sibling seam is still in flight HOLDS (the quiescence gate won't freeze
|
|
135
|
+
* yet), but a human must still be able to answer during that hold. So: register the gate with the
|
|
136
|
+
* broker immediately (it surfaces in the inbox/API at once), then race two outcomes —
|
|
137
|
+
* - the answer arrives (brokered poll) while holding ⇒ WITHDRAW the freeze wait and resolve
|
|
138
|
+
* in-process (the run never froze); or
|
|
139
|
+
* - quiescence is reached with no answer ⇒ the wait freezes, and the wake carries the answer.
|
|
140
|
+
* Once frozen the poll is frozen too (same process), so the race is only live during the hold.
|
|
141
|
+
* A register/poll failure degrades to the plain freeze wait — the gate still works, just without
|
|
142
|
+
* the answerable-while-held property.
|
|
143
|
+
*/
|
|
144
|
+
async freezeHumanInput(freeze, signal, key) {
|
|
145
|
+
const held = this.deps.heldInput;
|
|
146
|
+
if (held === undefined) {
|
|
147
|
+
// No held-input port wired: the gate can only be answered once it freezes.
|
|
148
|
+
return this.resolveFreezeAnswer(await this.freezeWait(freeze, signal), key);
|
|
149
|
+
}
|
|
150
|
+
await held.register(signal.seq, signal.humanInput);
|
|
151
|
+
const withdraw = new AbortController();
|
|
152
|
+
const poll = this.pollHeldAnswer(held, signal.seq, key, withdraw.signal);
|
|
153
|
+
const outcome = await Promise.race([
|
|
154
|
+
poll.then((answer) => ({ kind: "answered", answer })),
|
|
155
|
+
this.freezeWait(freeze, signal, withdraw.signal).then((o) => ({ kind: "froze", o })),
|
|
156
|
+
]);
|
|
157
|
+
if (outcome.kind === "answered") {
|
|
158
|
+
withdraw.abort(); // withdraw the still-holding freeze wait (no-op if it already froze)
|
|
159
|
+
return normalizeHumanInputResult(outcome.answer);
|
|
160
|
+
}
|
|
161
|
+
withdraw.abort(); // stop the poll loop; the wake path carries the answer
|
|
162
|
+
return this.resolveFreezeAnswer(outcome.o, key);
|
|
163
|
+
}
|
|
164
|
+
/** Poll the broker for a held gate's answer until it arrives or the wait withdraws. Resolves with
|
|
165
|
+
* the answer value; never rejects the run (a transient poll error just retries next tick). Runs
|
|
166
|
+
* OUTSIDE the quiescence gate (it is not run work — it must not block a freeze). */
|
|
167
|
+
async pollHeldAnswer(held, seq, key, abort) {
|
|
168
|
+
for (;;) {
|
|
169
|
+
if (abort.aborted)
|
|
170
|
+
return await new Promise(() => undefined); // frozen path won: never resolve
|
|
171
|
+
try {
|
|
172
|
+
const answers = await held.poll(seq);
|
|
173
|
+
if (key in answers)
|
|
174
|
+
return answers[key];
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* transient — retry next tick */
|
|
178
|
+
}
|
|
179
|
+
await new Promise((resolve) => {
|
|
180
|
+
const t = setTimeout(resolve, this.heldPollIntervalMs);
|
|
181
|
+
abort.addEventListener("abort", () => {
|
|
182
|
+
clearTimeout(t);
|
|
183
|
+
resolve();
|
|
184
|
+
}, { once: true });
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Map a froze/aborted freeze outcome to the gate's answer (or throw on an unexpected abort). */
|
|
189
|
+
resolveFreezeAnswer(outcome, key) {
|
|
190
|
+
if (outcome.kind !== "wake") {
|
|
191
|
+
throw new AppError(ErrorCode.INTERNAL_ERROR, `Freeze wait for a human-input gate ended unexpectedly (${outcome.kind})`, { kind: "unexpected_freeze_outcome", key });
|
|
192
|
+
}
|
|
193
|
+
return normalizeHumanInputResult(this.gateAnswer(outcome.wake, key));
|
|
194
|
+
}
|
|
130
195
|
/** The wake's answer for one gate key. A wake whose value is missing the parked gate means the
|
|
131
196
|
* control plane and the snapshot disagree about what this run was waiting for — a platform bug,
|
|
132
197
|
* failed loudly, never a retry. */
|
|
@@ -256,12 +321,7 @@ export class WorkerWorkflowHost {
|
|
|
256
321
|
};
|
|
257
322
|
const freeze = this.deps.freeze;
|
|
258
323
|
if (freeze !== undefined) {
|
|
259
|
-
|
|
260
|
-
const outcome = await this.freezeWait(freeze, signal);
|
|
261
|
-
if (outcome.kind !== "wake") {
|
|
262
|
-
throw new AppError(ErrorCode.INTERNAL_ERROR, "Suspend aborted surfaced to a human-input seam (the coordinator retries these)", { kind: "unexpected_abort", seq });
|
|
263
|
-
}
|
|
264
|
-
return normalizeHumanInputResult(this.gateAnswer(outcome.wake, key));
|
|
324
|
+
return await this.freezeHumanInput(freeze, signal, key);
|
|
265
325
|
}
|
|
266
326
|
return this.suspend(signal);
|
|
267
327
|
}
|
package/native/reseed.c
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* bw_reseed — the clause-3 userspace-CSPRNG reseed (SNAPSHOT_UNIQUENESS_CONTRACT).
|
|
3
|
+
*
|
|
4
|
+
* A memory snapshot freezes OpenSSL's DRBG state, so two clones of one base snapshot hand every
|
|
5
|
+
* run byte-identical `crypto.*` output. The kernel CSPRNG diverges across clones (VMGenID), but
|
|
6
|
+
* that reseed never reaches OpenSSL. A pure-JS monkeypatch was proven insufficient (named ESM
|
|
7
|
+
* imports of node:crypto bypass it), so this native step reseeds OpenSSL's DRBG UNDERNEATH every
|
|
8
|
+
* caller — the only robust fix.
|
|
9
|
+
*
|
|
10
|
+
* OpenSSL 3 DRBG chain: the primary DRBG seeds from the OS entropy source; the public + private
|
|
11
|
+
* DRBGs (what RAND_bytes / Node crypto actually draw from) seed from the primary. We reseed the
|
|
12
|
+
* primary first (pulling the now-diverged OS entropy) then the public + private so the fresh
|
|
13
|
+
* entropy propagates immediately, and call RAND_poll() as a belt. Everything is guarded on NULL so
|
|
14
|
+
* a non-default RAND provider degrades to whatever succeeded rather than crashing.
|
|
15
|
+
*
|
|
16
|
+
* Symbols (RAND_get0_*, EVP_RAND_reseed, RAND_poll) come from the OpenSSL that Node links; on a
|
|
17
|
+
* Node build that does not export them the addon fails to load and the JS layer no-ops with a warn
|
|
18
|
+
* (uniqueness_reseed.ts). Linux-only in practice (the snapshot substrate is Linux microVMs).
|
|
19
|
+
*/
|
|
20
|
+
#include <node_api.h>
|
|
21
|
+
#include <openssl/rand.h>
|
|
22
|
+
#include <openssl/evp.h>
|
|
23
|
+
|
|
24
|
+
static int reseed_ctx(EVP_RAND_CTX *ctx) {
|
|
25
|
+
if (ctx == NULL) return 1; /* absent layer: nothing to do, not a failure */
|
|
26
|
+
return EVP_RAND_reseed(ctx, /*prediction_resistance=*/0, NULL, 0, NULL, 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static napi_value Reseed(napi_env env, napi_callback_info info) {
|
|
30
|
+
int ok = 1;
|
|
31
|
+
/* Reseed the primary FIRST (it pulls fresh OS entropy), then the layers that serve draws. */
|
|
32
|
+
if (!reseed_ctx(RAND_get0_primary(NULL))) ok = 0;
|
|
33
|
+
if (!reseed_ctx(RAND_get0_public(NULL))) ok = 0;
|
|
34
|
+
if (!reseed_ctx(RAND_get0_private(NULL))) ok = 0;
|
|
35
|
+
/* Legacy belt: also nudge the default method's entropy pool. */
|
|
36
|
+
RAND_poll();
|
|
37
|
+
|
|
38
|
+
napi_value result;
|
|
39
|
+
napi_get_boolean(env, ok ? true : false, &result);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static napi_value Init(napi_env env, napi_value exports) {
|
|
44
|
+
napi_value fn;
|
|
45
|
+
if (napi_create_function(env, "reseed", NAPI_AUTO_LENGTH, Reseed, NULL, &fn) != napi_ok) {
|
|
46
|
+
return exports;
|
|
47
|
+
}
|
|
48
|
+
napi_set_named_property(env, exports, "reseed", fn);
|
|
49
|
+
return exports;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boardwalk-labs/runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,10 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"dist",
|
|
39
39
|
"README.md",
|
|
40
|
-
"LICENSE"
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"native",
|
|
42
|
+
"binding.gyp",
|
|
43
|
+
"prebuilds"
|
|
41
44
|
],
|
|
42
45
|
"scripts": {
|
|
43
46
|
"build": "tsc -p tsconfig.build.json",
|
|
@@ -46,13 +49,15 @@
|
|
|
46
49
|
"format": "prettier --write .",
|
|
47
50
|
"format:check": "prettier --check .",
|
|
48
51
|
"test": "vitest run --coverage",
|
|
49
|
-
"test:watch": "vitest"
|
|
52
|
+
"test:watch": "vitest",
|
|
53
|
+
"prebuild": "prebuildify --napi --strip -t 24.0.0"
|
|
50
54
|
},
|
|
51
55
|
"dependencies": {
|
|
52
56
|
"zod": "^4.0.0",
|
|
53
57
|
"@boardwalk-labs/engine": "^0.1.29",
|
|
54
58
|
"@boardwalk-labs/workflow": "^0.1.17",
|
|
55
|
-
"tar": "^7.5.16"
|
|
59
|
+
"tar": "^7.5.16",
|
|
60
|
+
"node-gyp-build": "^4.8.4"
|
|
56
61
|
},
|
|
57
62
|
"devDependencies": {
|
|
58
63
|
"@eslint/js": "^9.18.0",
|
|
@@ -62,7 +67,9 @@
|
|
|
62
67
|
"prettier": "^3.4.0",
|
|
63
68
|
"typescript": "^5.6.0",
|
|
64
69
|
"typescript-eslint": "^8.20.0",
|
|
65
|
-
"vitest": "^3.0.0"
|
|
70
|
+
"vitest": "^3.0.0",
|
|
71
|
+
"prebuildify": "^6.0.1",
|
|
72
|
+
"node-gyp": "^11.0.0"
|
|
66
73
|
},
|
|
67
74
|
"bin": {
|
|
68
75
|
"boardwalk-runner": "./dist/bin.js"
|
|
Binary file
|