mandrel-platform 1.1.0 → 1.2.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.
@@ -0,0 +1,554 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * runner-env-drift.test.mjs — node:test suite for the operator-run pool checker
4
+ * shipped at `templates/runner/check-runner-env-drift.sh` (Story #353).
5
+ *
6
+ * WHY THIS CHECKER NEEDS A SUITE AT ALL
7
+ *
8
+ * A runner's `<RUNNER_DIR>/.env` is invisible to every other observer the fleet
9
+ * has. `scripts/check-runner-health.mjs` reaches runners through
10
+ * `GET /repos/{owner}/{repo}/actions/runners`, which reports name, labels and
11
+ * online status — not local configuration. So a half-provisioned pool never
12
+ * presents as a configuration fault; it presents as an unattributable
13
+ * behavioural difference between two runs of the same job (issue #343: two
14
+ * hooked runners sat 5m29s in `Set up runner` while the same job on an unhooked
15
+ * runner finished in 54 seconds). This checker is the only thing that names the
16
+ * odd runners out, so what the suite pins is that it NAMES them — not merely
17
+ * that it noticed drift exists.
18
+ *
19
+ * The three properties worth stating up front, because each has a counterpart
20
+ * failure that would make the tool actively misleading:
21
+ *
22
+ * 1. Drift is a key set on SOME runners but not all. A key absent from EVERY
23
+ * runner is a uniform gap — a fleet that deliberately has not adopted a
24
+ * key must not be a standing alarm, or the operator learns to ignore the
25
+ * exit code.
26
+ * 2. One broken runner must not hide the pool. A directory with no readable
27
+ * `.env` is recorded as all keys unset and the walk continues; an early
28
+ * exit there would silently shrink the sample the drift verdict is
29
+ * computed over.
30
+ * 3. The run is read-only. This is pointed at production runner roots by an
31
+ * operator; it must never be the reason a runner's configuration changed.
32
+ *
33
+ * The suite executes the real script against synthetic pool fixtures, following
34
+ * the `scripts/job-cleanup-hook.test.mjs` precedent for shell-under-test.
35
+ * Fixtures are a handful of directories: every assertion is on report content
36
+ * and exit code, both count-independent, so planting volume would buy no signal
37
+ * (and costs ~17ms/file on a dev Mac whose endpoint security scans every write).
38
+ *
39
+ * INTERPRETER COVERAGE — what each assertion actually proves
40
+ *
41
+ * The fleet is macOS, where `/bin/bash` is 3.2 (Apple cannot ship a GPL3 bash).
42
+ * That version differs from bash 4.4+ in ways no source scan can see, so this
43
+ * suite is explicit about which interpreter it ran under rather than inheriting
44
+ * whatever `bash` PATH happens to resolve:
45
+ *
46
+ * • `resolveBash()` prefers `/bin/bash`, falls back to PATH `bash`, and is
47
+ * overridable with `RUNNER_KIT_BASH`. Its resolved banner is asserted and
48
+ * printed, so a run can never claim 3.2 coverage it did not have.
49
+ * • The bash-4 construct denylist (AC-8) is a SOURCE scan and proves the same
50
+ * thing under any interpreter.
51
+ * • Every other test is a real execution and proves its property only for
52
+ * `BASH.banner`. On the macOS CI job and on a dev Mac that is genuinely
53
+ * 3.2; on ubuntu it is bash 5.
54
+ *
55
+ * The divergence that motivated this (Story #354 audit follow-up): under
56
+ * `set -u`, bash 3.2 aborts on `"${arr[@]}"` when the array is EMPTY, where
57
+ * bash 4.4+ expands to nothing. The checker runs under `set -u` and builds
58
+ * three accumulators, so an unguarded expansion would pass a bash-5-only CI and
59
+ * then fail with `unbound variable` on the fleet at the operator's first real
60
+ * invocation. `ci.yml`'s `runner-kit-bash32` job runs this suite on
61
+ * `macos-latest` against the system 3.2 for exactly that reason; the canary
62
+ * test below asserts the divergence is actually present before the
63
+ * empty-accumulator test claims to have exercised it.
64
+ *
65
+ * Run: node --test scripts/runner-env-drift.test.mjs
66
+ * RUNNER_KIT_BASH=/bin/bash node --test scripts/runner-env-drift.test.mjs
67
+ */
68
+
69
+ import assert from "node:assert/strict";
70
+ import { execFileSync, spawnSync } from "node:child_process";
71
+ import {
72
+ copyFileSync,
73
+ mkdirSync,
74
+ mkdtempSync,
75
+ readdirSync,
76
+ readFileSync,
77
+ rmSync,
78
+ statSync,
79
+ writeFileSync,
80
+ existsSync,
81
+ } from "node:fs";
82
+ import { tmpdir } from "node:os";
83
+ import { dirname, join } from "node:path";
84
+ import { fileURLToPath } from "node:url";
85
+ import { after, test } from "node:test";
86
+
87
+ const HERE = dirname(fileURLToPath(import.meta.url));
88
+ const SCRIPT = join(HERE, "..", "templates", "runner", "check-runner-env-drift.sh");
89
+ const RUNBOOK = join(HERE, "..", "templates", "runbooks", "runner-provisioning.md");
90
+
91
+ /** The four keys `templates/runner/.env.example` mandates. */
92
+ const HOOK = "ACTIONS_RUNNER_HOOK_JOB_STARTED";
93
+ const MANDATED = [HOOK, "RUNNER_TOOL_CACHE", "AGENT_TOOLSDIRECTORY", "LANG"];
94
+
95
+ /** Representative values — the checker reports PRESENCE, never value. */
96
+ const VALUES = {
97
+ ACTIONS_RUNNER_HOOK_JOB_STARTED: "/Users/ci/runners/a/job-cleanup.sh",
98
+ RUNNER_TOOL_CACHE: "/Users/ci/runners/a/_work/_tool",
99
+ AGENT_TOOLSDIRECTORY: "/Users/ci/runners/a/_work/_tool",
100
+ LANG: "en_US.UTF-8",
101
+ };
102
+
103
+ /** Sandboxes created by the suite, torn down in `after`. */
104
+ const SANDBOXES = [];
105
+
106
+ after(() => {
107
+ for (const dir of SANDBOXES) {
108
+ rmSync(dir, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ /**
113
+ * Render an `.env` body setting exactly the named keys, with the comment
114
+ * preamble a real runner `.env` carries.
115
+ *
116
+ * @param {string[]} keys
117
+ * @returns {string}
118
+ */
119
+ function envWith(keys) {
120
+ const lines = ["# .env — per-runner environment (fixture)", ""];
121
+ for (const key of keys) {
122
+ lines.push(`${key}=${VALUES[key]}`);
123
+ }
124
+ lines.push("");
125
+ return lines.join("\n");
126
+ }
127
+
128
+ /**
129
+ * Build a synthetic pool root.
130
+ *
131
+ * Each entry maps a child directory name to its spec:
132
+ * `keys` — mandated keys to set in that runner's `.env` (default: all four)
133
+ * `env` — raw `.env` body, overriding `keys`
134
+ * `noEnv` — create no `.env` at all
135
+ * `isRunner` — false to omit `config.sh`, i.e. not a runner directory
136
+ *
137
+ * @param {Record<string, {keys?: string[], env?: string, noEnv?: boolean, isRunner?: boolean}>} spec
138
+ * @returns {string} absolute pool root
139
+ */
140
+ function makePool(spec) {
141
+ const root = mkdtempSync(join(tmpdir(), "runner-env-drift-"));
142
+ SANDBOXES.push(root);
143
+
144
+ for (const [name, entry] of Object.entries(spec)) {
145
+ const dir = join(root, name);
146
+ mkdirSync(dir, { recursive: true });
147
+
148
+ // `config.sh` is the runner predicate — the runbook mandates one directory
149
+ // per runner under a common root, and this is what keeps unrelated
150
+ // siblings out of the report without inventing a naming convention.
151
+ if (entry.isRunner !== false) {
152
+ writeFileSync(join(dir, "config.sh"), "#!/bin/sh\nexit 0\n");
153
+ }
154
+ if (entry.noEnv) {
155
+ continue;
156
+ }
157
+ writeFileSync(join(dir, ".env"), entry.env ?? envWith(entry.keys ?? MANDATED));
158
+ }
159
+
160
+ return root;
161
+ }
162
+
163
+ /**
164
+ * Resolve the interpreter every execution test runs under, and record WHICH one
165
+ * it is. Bare `bash` from PATH is deliberately not used: it silently varies by
166
+ * host (3.2 on a stock Mac, 5.x on ubuntu), so a suite that inherits it cannot
167
+ * say what its passes prove. `/bin/bash` is preferred because the fleet's shell
168
+ * is the stricter one; `RUNNER_KIT_BASH` overrides for a deliberate cross-check.
169
+ *
170
+ * @returns {{ cmd: string, banner: string, major: number|null }}
171
+ */
172
+ function resolveBash() {
173
+ const candidates = process.env.RUNNER_KIT_BASH
174
+ ? [process.env.RUNNER_KIT_BASH]
175
+ : ["/bin/bash", "bash"];
176
+ for (const cmd of candidates) {
177
+ let banner;
178
+ try {
179
+ banner = execFileSync(cmd, ["--version"], { encoding: "utf8" }).split("\n")[0].trim();
180
+ } catch {
181
+ continue;
182
+ }
183
+ const m = /version (\d+)\./.exec(banner);
184
+ return { cmd, banner, major: m ? Number(m[1]) : null };
185
+ }
186
+ throw new Error(
187
+ `no usable bash interpreter (tried ${candidates.join(", ")}) — this suite executes a shell script`,
188
+ );
189
+ }
190
+
191
+ const BASH = resolveBash();
192
+
193
+ /** True when the resolved interpreter is the fleet's bash 3.x, not 4.4+. */
194
+ const IS_BASH_3X = BASH.major === 3;
195
+
196
+ /**
197
+ * Run the checker under the RESOLVED interpreter, capturing both streams.
198
+ *
199
+ * stderr is returned, not discarded: a bash-3.2 `unbound variable` abort writes
200
+ * there and exits non-zero, which would otherwise be indistinguishable from the
201
+ * checker's own deliberate exit 1 (drift) or exit 2 (usage).
202
+ *
203
+ * @param {string[]} args
204
+ * @param {string} [scriptPath] — defaults to the shipped script
205
+ * @returns {{ status: number, stdout: string, stderr: string }}
206
+ */
207
+ function runChecker(args, scriptPath = SCRIPT) {
208
+ const res = spawnSync(BASH.cmd, [scriptPath, ...args], {
209
+ encoding: "utf8",
210
+ timeout: 60_000,
211
+ });
212
+ return {
213
+ status: res.status ?? 1,
214
+ stdout: res.stdout ?? "",
215
+ stderr: res.stderr ?? "",
216
+ };
217
+ }
218
+
219
+ test("AC-1: the shipped script is executable", () => {
220
+ const mode = statSync(SCRIPT).mode;
221
+
222
+ assert.equal(
223
+ (mode & 0o100) !== 0,
224
+ true,
225
+ "the kit-copy step in the runbook chmods the hook but copies this verbatim — it must ship executable",
226
+ );
227
+ });
228
+
229
+ test("AC-2: names every runner missing a key that others have, and exits non-zero", () => {
230
+ // The 16-of-19 shape from issue #343, scaled down: the hook is configured on
231
+ // one runner and absent from two.
232
+ const root = makePool({
233
+ "runner-a": { keys: MANDATED },
234
+ "runner-b": { keys: MANDATED.filter((key) => key !== HOOK) },
235
+ "runner-c": { keys: MANDATED.filter((key) => key !== HOOK) },
236
+ });
237
+
238
+ const { status, stdout } = runChecker(["--pool-root", root]);
239
+
240
+ assert.notEqual(status, 0, "drift must be reported through the exit code — that is the alert channel");
241
+ assert.match(stdout, /runner-b/, "the partially-provisioned runner must be named, not just counted");
242
+ assert.match(stdout, /runner-c/, "the partially-provisioned runner must be named, not just counted");
243
+ assert.match(stdout, new RegExp(HOOK), "the drifting key must be named");
244
+ });
245
+
246
+ test("AC-3: a fully provisioned pool exits 0", () => {
247
+ const root = makePool({
248
+ "runner-a": { keys: MANDATED },
249
+ "runner-b": { keys: MANDATED },
250
+ "runner-c": { keys: MANDATED },
251
+ });
252
+
253
+ const { status } = runChecker(["--pool-root", root]);
254
+
255
+ assert.equal(status, 0);
256
+ });
257
+
258
+ test("AC-4: a key absent from every runner is a uniform gap, not an alarm", () => {
259
+ // A fleet that has deliberately not adopted a key must not be a standing
260
+ // non-zero exit, or the operator learns to ignore the alert channel.
261
+ const withoutLang = MANDATED.filter((key) => key !== "LANG");
262
+ const root = makePool({
263
+ "runner-a": { keys: withoutLang },
264
+ "runner-b": { keys: withoutLang },
265
+ "runner-c": { keys: withoutLang },
266
+ });
267
+
268
+ const { status, stdout } = runChecker(["--pool-root", root]);
269
+
270
+ assert.equal(status, 0, "a uniform gap is not drift");
271
+ assert.match(
272
+ stdout,
273
+ /LANG: uniformly unset/,
274
+ "the gap must still be visible in the report — silence would hide a fleet-wide miss",
275
+ );
276
+ });
277
+
278
+ test("AC-5: a runner with no readable .env is recorded as all-unset and the walk continues", () => {
279
+ const root = makePool({
280
+ "runner-a": { keys: MANDATED },
281
+ "runner-broken": { noEnv: true },
282
+ "runner-c": { keys: MANDATED },
283
+ });
284
+
285
+ const { status, stdout } = runChecker(["--pool-root", root]);
286
+
287
+ assert.notEqual(status, 0, "a runner missing every mandated key while others have them is drift");
288
+ assert.match(stdout, /runner-a/, "an early exit on the broken runner would hide the rest of the pool");
289
+ assert.match(stdout, /runner-c/, "an early exit on the broken runner would hide the rest of the pool");
290
+
291
+ const brokenLine = stdout.split("\n").find((line) => line.includes("runner-broken"));
292
+ assert.ok(brokenLine, "the unreadable runner must appear in the per-runner report");
293
+ for (const key of MANDATED) {
294
+ assert.match(
295
+ brokenLine,
296
+ new RegExp(key),
297
+ `an unreadable .env must record ${key} as unset, not as unknown or absent from the report`,
298
+ );
299
+ }
300
+ });
301
+
302
+ test("AC-6: a child directory without config.sh is not a runner and never appears", () => {
303
+ const root = makePool({
304
+ "runner-a": { keys: MANDATED },
305
+ "runner-b": { keys: MANDATED },
306
+ // A plausible sibling on a real runner host: a shared scratch dir that
307
+ // happens to carry an `.env`. Counting it would fabricate drift.
308
+ "shared-scratch": { isRunner: false, keys: [] },
309
+ });
310
+
311
+ const { status, stdout } = runChecker(["--pool-root", root]);
312
+
313
+ assert.equal(status, 0, "a non-runner sibling with no keys must not fabricate drift");
314
+ assert.equal(
315
+ stdout.includes("shared-scratch"),
316
+ false,
317
+ "only directories containing config.sh are runners",
318
+ );
319
+ });
320
+
321
+ test("AC-7: the pool root defaults to the parent of the script's own directory", () => {
322
+ // The kit installs the checker into <RUNNER_DIR>, so its own parent IS the
323
+ // pool root — an operator can run it with no arguments from any runner root.
324
+ const root = makePool({
325
+ "runner-a": { keys: MANDATED },
326
+ "runner-b": { keys: MANDATED.filter((key) => key !== HOOK) },
327
+ });
328
+ const installed = join(root, "runner-a", "check-runner-env-drift.sh");
329
+ copyFileSync(SCRIPT, installed);
330
+
331
+ const { status, stdout } = runChecker([], installed);
332
+
333
+ assert.notEqual(status, 0);
334
+ assert.match(stdout, /runner-a/);
335
+ assert.match(stdout, /runner-b/, "the default pool root must reach sibling runners, not just its own");
336
+ });
337
+
338
+ test("AC-7: --pool-root overrides the default", () => {
339
+ const root = makePool({
340
+ "runner-a": { keys: MANDATED },
341
+ "runner-b": { keys: MANDATED.filter((key) => key !== HOOK) },
342
+ });
343
+
344
+ const { status, stdout } = runChecker(["--pool-root", root]);
345
+
346
+ assert.notEqual(status, 0);
347
+ assert.match(stdout, new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "the report names the pool it walked");
348
+ assert.match(stdout, /runner-b/);
349
+ });
350
+
351
+ test("AC-8: the script uses no bash-4-only construct (source scan — interpreter-independent)", () => {
352
+ // These are the bash-4 constructs a maintainer reaches for first when
353
+ // accumulating per-runner state, and each fails with a SYNTAX error rather
354
+ // than a wrong answer — i.e. the operator's first real invocation is where
355
+ // they would find out. A source scan is the right shape for this class
356
+ // precisely because it does not depend on which bash ran the suite.
357
+ //
358
+ // It is NOT sufficient on its own: the runtime divergences below are
359
+ // invisible to any denylist. See the two tests that follow.
360
+ const source = readFileSync(SCRIPT, "utf8");
361
+ const code = source
362
+ .split("\n")
363
+ .filter((line) => !/^\s*#/.test(line))
364
+ .join("\n");
365
+
366
+ assert.equal(/declare\s+-A/.test(code), false, "associative arrays are bash 4+");
367
+ assert.equal(/\bmapfile\b/.test(code), false, "mapfile is bash 4+");
368
+ assert.equal(/\breadarray\b/.test(code), false, "readarray is bash 4+");
369
+ assert.equal(/\$\{[A-Za-z_][A-Za-z0-9_]*,,\}/.test(code), false, "case-conversion expansion is bash 4+");
370
+ });
371
+
372
+ test("the suite reports which interpreter its execution tests actually prove", () => {
373
+ // A passing suite must never be readable as "bash 3.2 verified" when it ran
374
+ // under bash 5. This test does not gate on the version — ubuntu CI legitimately
375
+ // has only bash 5 — it gates on the resolution being KNOWN and reported.
376
+ assert.match(BASH.banner, /GNU bash, version \d+\./, "could not identify the interpreter");
377
+ assert.notEqual(BASH.major, null, "interpreter major version is unparseable");
378
+ console.log(
379
+ ` ℹ execution tests ran under: ${BASH.cmd} — ${BASH.banner}` +
380
+ (IS_BASH_3X
381
+ ? " [fleet-equivalent bash 3.x]"
382
+ : " [NOT the fleet's 3.x — 3.2-only regressions cannot surface in this run]"),
383
+ );
384
+ });
385
+
386
+ test("canary: the bash-3.2 empty-array divergence is real on a 3.x interpreter", (t) => {
387
+ // The empty-accumulator test below is only meaningful if the interpreter it
388
+ // runs under actually exhibits the hazard. Assert the divergence directly, so
389
+ // the guard can never be "passing" against a bash that would accept an
390
+ // unguarded expansion anyway.
391
+ if (!IS_BASH_3X) {
392
+ t.skip(`interpreter is bash ${BASH.major}.x — 4.4+ expands an empty "\${arr[@]}" to nothing by design`);
393
+ return;
394
+ }
395
+ const res = spawnSync(BASH.cmd, ["-c", 'set -u; arr=(); for x in "${arr[@]}"; do :; done'], {
396
+ encoding: "utf8",
397
+ });
398
+ assert.notEqual(res.status, 0, "expected bash 3.x to abort on an empty array under `set -u`");
399
+ assert.match(res.stderr, /unbound variable/);
400
+ });
401
+
402
+ test("no reachable path expands a possibly-empty accumulator under `set -u`", () => {
403
+ // The checker runs under `set -u` and builds three accumulators —
404
+ // `runner_names`, `unset_keys`, `unset_names`. Each is expanded only behind a
405
+ // non-empty guard today. This drives every scenario that empties one of them
406
+ // and asserts the script never aborts on an unbound expansion, so a later
407
+ // edit that drops a guard is caught rather than shipped.
408
+ //
409
+ // Under bash 3.x this is a real regression gate. Under 4.4+ it still checks
410
+ // exit codes and output, but the divergence itself cannot fire — which is
411
+ // what the macOS `runner-kit-bash32` CI job exists to cover.
412
+ const scenarios = [
413
+ {
414
+ what: "unset_keys and unset_names both empty (every key set on every runner)",
415
+ pool: makePool({ "runner-a": { keys: MANDATED }, "runner-b": { keys: MANDATED } }),
416
+ expect: 0,
417
+ },
418
+ {
419
+ what: "unset_names empty for the uniformly-set keys, non-empty for the drifting one",
420
+ pool: makePool({
421
+ "runner-a": { keys: MANDATED },
422
+ "runner-b": { keys: MANDATED.filter((k) => k !== HOOK) },
423
+ }),
424
+ expect: 1,
425
+ },
426
+ {
427
+ what: "every accumulator empty (a key set on no runner at all)",
428
+ pool: makePool({ "runner-a": { keys: [] }, "runner-b": { keys: [] } }),
429
+ expect: 0,
430
+ },
431
+ {
432
+ what: "runner_names empty (pool root holds no runner directories)",
433
+ pool: makePool({ "not-a-runner": { isRunner: false } }),
434
+ expect: 2,
435
+ },
436
+ ];
437
+
438
+ for (const { what, pool, expect } of scenarios) {
439
+ const res = runChecker(["--pool-root", pool]);
440
+ assert.doesNotMatch(
441
+ res.stderr,
442
+ /unbound variable/,
443
+ `aborted on an unguarded empty-array expansion — ${what}`,
444
+ );
445
+ assert.equal(res.status, expect, `unexpected exit for: ${what}\nstderr: ${res.stderr}`);
446
+ }
447
+ });
448
+
449
+ test("AC-9: the run is read-only against the pool", () => {
450
+ const root = makePool({
451
+ "runner-a": { keys: MANDATED },
452
+ "runner-b": { keys: MANDATED.filter((key) => key !== HOOK) },
453
+ "runner-broken": { noEnv: true },
454
+ });
455
+
456
+ const snapshot = () => {
457
+ const state = {};
458
+ for (const name of readdirSync(root).sort()) {
459
+ state[name] = readdirSync(join(root, name)).sort();
460
+ const envPath = join(root, name, ".env");
461
+ state[`${name}/.env`] = existsSync(envPath) ? readFileSync(envPath, "utf8") : null;
462
+ }
463
+ return state;
464
+ };
465
+
466
+ const before = snapshot();
467
+ runChecker(["--pool-root", root]);
468
+
469
+ assert.deepEqual(
470
+ snapshot(),
471
+ before,
472
+ "the checker wrote into a runner root — it is pointed at production runners and must only read",
473
+ );
474
+ });
475
+
476
+ test("a commented-out assignment does not count as set", () => {
477
+ // `.env.example` ships every key inside a block of explanatory comments, so a
478
+ // half-applied copy where the operator never uncommented a line is the most
479
+ // likely real drift shape. Matching a key name anywhere in the file would
480
+ // report that runner as provisioned.
481
+ const root = makePool({
482
+ "runner-a": { keys: MANDATED },
483
+ "runner-b": {
484
+ env: `# ${HOOK}=/Users/ci/runners/b/job-cleanup.sh\nRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANG=${VALUES.LANG}\n`,
485
+ },
486
+ });
487
+
488
+ const { status, stdout } = runChecker(["--pool-root", root]);
489
+
490
+ assert.notEqual(status, 0);
491
+ assert.match(stdout, /runner-b/);
492
+ });
493
+
494
+ test("a leading-whitespace assignment counts as set", () => {
495
+ const root = makePool({
496
+ "runner-a": { keys: MANDATED },
497
+ "runner-b": {
498
+ env: ` ${HOOK}=/Users/ci/runners/b/job-cleanup.sh\n\tRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANG=${VALUES.LANG}\n`,
499
+ },
500
+ });
501
+
502
+ const { status } = runChecker(["--pool-root", root]);
503
+
504
+ assert.equal(status, 0, "indentation is not a configuration difference");
505
+ });
506
+
507
+ test("a longer key that merely starts with a mandated key does not count as set", () => {
508
+ // `LANGUAGE=` must not satisfy `LANG`. A prefix match here would report a
509
+ // runner as provisioned on the strength of an unrelated variable.
510
+ const root = makePool({
511
+ "runner-a": { keys: MANDATED },
512
+ "runner-b": {
513
+ env: `${HOOK}=/Users/ci/runners/b/job-cleanup.sh\nRUNNER_TOOL_CACHE=${VALUES.RUNNER_TOOL_CACHE}\nAGENT_TOOLSDIRECTORY=${VALUES.AGENT_TOOLSDIRECTORY}\nLANGUAGE=en_US\n`,
514
+ },
515
+ });
516
+
517
+ const { status, stdout } = runChecker(["--pool-root", root]);
518
+
519
+ assert.notEqual(status, 0);
520
+ assert.match(stdout, /runner-b/);
521
+ });
522
+
523
+ test("a pool root holding no runner directories is a usage error, not a clean pool", () => {
524
+ // Reporting "no drift" over an empty walk is the worst possible answer: the
525
+ // operator reads a green exit as evidence the fleet is uniform.
526
+ const root = makePool({ "shared-scratch": { isRunner: false, noEnv: true } });
527
+
528
+ const { status } = runChecker(["--pool-root", root]);
529
+
530
+ assert.equal(status, 2, "a pool root with no runners must be distinguishable from a clean pool");
531
+ });
532
+
533
+ test("an unknown flag is rejected rather than silently ignored", () => {
534
+ const root = makePool({ "runner-a": { keys: MANDATED } });
535
+
536
+ const { status } = runChecker(["--pool-root", root, "--fix"]);
537
+
538
+ assert.equal(status, 2, "this tool never repairs — a flag it does not implement must not appear to work");
539
+ });
540
+
541
+ test("AC-10: the runbook installs and invokes the checker alongside the hook", () => {
542
+ // `assert.ok` rather than `assert.match`: a failing `match` prints the whole
543
+ // runbook as the actual value, which buries the one line that is wrong.
544
+ const runbook = readFileSync(RUNBOOK, "utf8");
545
+
546
+ assert.ok(
547
+ /cp .*templates\/runner\/check-runner-env-drift\.sh/.test(runbook),
548
+ "an operator following the runbook must end up with the checker on the host",
549
+ );
550
+ assert.ok(
551
+ /\.\/check-runner-env-drift\.sh/.test(runbook),
552
+ "the runbook must show how to invoke it, not just how to copy it",
553
+ );
554
+ });
@@ -142,6 +142,8 @@ Copy the kit from the platform payload into the runner root:
142
142
  ```bash
143
143
  cp node_modules/mandrel-platform/templates/runner/job-cleanup.sh <RUNNER_DIR>/job-cleanup.sh
144
144
  chmod +x <RUNNER_DIR>/job-cleanup.sh
145
+ cp node_modules/mandrel-platform/templates/runner/check-runner-env-drift.sh <RUNNER_DIR>/check-runner-env-drift.sh
146
+ chmod +x <RUNNER_DIR>/check-runner-env-drift.sh
145
147
  cp node_modules/mandrel-platform/templates/runner/.env.example <RUNNER_DIR>/.env
146
148
  ```
147
149
 
@@ -167,8 +169,48 @@ with the runner root's absolute path. The resulting file wires:
167
169
  (two env names, one dir; some actions read the legacy name).
168
170
  - `LANG=en_US.UTF-8`.
169
171
 
170
- The hook needs no per-runner editing: it derives `RUNNER_DIR` from its own
171
- location, so the same file works verbatim on every runner.
172
+ Neither shipped script needs per-runner editing: each derives its paths from
173
+ its own location, so the same files work verbatim on every runner.
174
+
175
+ ### Confirm the pool is uniform (`check-runner-env-drift.sh`)
176
+
177
+ Run this **after provisioning each runner**, and again whenever two runners
178
+ behave differently on the same job. It walks the pool and names the runners
179
+ missing any of the four mandated keys:
180
+
181
+ ```bash
182
+ cd <RUNNER_DIR>
183
+ ./check-runner-env-drift.sh # pool root = this dir's parent
184
+ ./check-runner-env-drift.sh --pool-root <POOL_ROOT>
185
+ ```
186
+
187
+ The pool root is the directory holding one subdirectory per runner (§1); a
188
+ child directory counts as a runner iff it contains `config.sh`. The checker is
189
+ read-only — it never writes into a runner root and never touches a service.
190
+
191
+ | Exit | Meaning | Operator response |
192
+ |------|---------|-------------------|
193
+ | `0` | No drift. Every mandated key is set on every runner, or unset on every runner. | None. |
194
+ | `1` | **Drift** — a key is set on some runners but not all. | Copy `.env.example` onto each runner the report names, substitute its `<RUNNER_DIR>`, then `./svc.sh stop && ./svc.sh start` on those runners so they reload `.env`. |
195
+ | `2` | Usage error — bad flag, or a pool root holding no runner directories. | Re-check `--pool-root`. |
196
+
197
+ A key absent from **every** runner is reported as a uniform gap and does *not*
198
+ exit non-zero, so a fleet that has deliberately not adopted a key is not a
199
+ standing alarm.
200
+
201
+ **Why this check exists at all:** `scripts/check-runner-health.mjs` monitors
202
+ the fleet through the GitHub runners API, which reports a runner's name,
203
+ labels and online status — it cannot see `<RUNNER_DIR>/.env`. So partial
204
+ provisioning never surfaces as a configuration fault; it surfaces as an
205
+ unattributable behavioural difference between two runs of the same job. In
206
+ issue #343, 16 of 19 runners on one host carried the hook, and the resulting
207
+ `Set up runner` spread (5m29s against 54s) took far longer to attribute than
208
+ reading nineteen `.env` files would have.
209
+
210
+ Do **not** wire this into `ACTIONS_RUNNER_HOOK_JOB_STARTED`. That hook runs
211
+ inside the job's clock, where every read is billed to `Set up runner` and
212
+ counts against the job's `timeout-minutes` — the exact cost model that made
213
+ #343 a job-killer. This is an operator-run tool.
172
214
 
173
215
  ## 5. Install as a launchd service (`svc.sh`)
174
216
 
@@ -199,10 +241,12 @@ The runner loads `.env` at service start — after any `.env` change, restart:
199
241
  `svc.sh`.
200
242
  - **Kit updates.** The hook and `.env.example` are versioned in
201
243
  mandrel-platform. On a platform release that touches `templates/runner/`,
202
- re-copy `job-cleanup.sh` (verbatim — it is parameterized) and diff
203
- `.env.example` against the live `.env`, then `./svc.sh stop && ./svc.sh
204
- start`. There is no `mandrel sync` equivalent for a runner host's
205
- filesystem — this is an operator-applied step.
244
+ re-copy `job-cleanup.sh` and `check-runner-env-drift.sh` (both verbatim —
245
+ they are parameterized) and diff `.env.example` against the live `.env`,
246
+ then `./svc.sh stop && ./svc.sh start`. There is no `mandrel sync`
247
+ equivalent for a runner host's filesystem — this is an operator-applied
248
+ step. Re-run `./check-runner-env-drift.sh` afterwards: a kit update applied
249
+ to some runners and not others is exactly the drift it reports.
206
250
  - **Token/registration rotation.** Registration tokens are one-shot at
207
251
  config time; nothing persists to rotate. To move a runner between repos or
208
252
  rename it: `./svc.sh stop && ./svc.sh uninstall && ./config.sh remove