@bongos/core 1.19.876 → 1.19.877

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.bongos-core.json +93 -38
  2. package/.gitattributes +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -1
  9. package/docs/api/openapi.json +149 -3
  10. package/docs/api-reference.md +4 -2
  11. package/docs/copy-inventory.md +7 -6
  12. package/docs/copy-registry.json +16 -7
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/recipes/autobongos-windows-host.md +138 -0
  15. package/docs/recipes/instance-deploy.md +29 -3
  16. package/modules/autonomy/cadence.js +276 -0
  17. package/modules/autonomy/db.js +47 -0
  18. package/modules/autonomy/migrations/autonomy_002_runner_heartbeat.sql +93 -0
  19. package/modules/autonomy/routes/autonomy.js +69 -0
  20. package/modules/autonomy/runner-health.js +97 -0
  21. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  22. package/modules/hall-ui/public/gate.html +4 -1
  23. package/modules/hall-ui/public/gate.js +51 -2
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/autobongos-run.js +368 -11
  27. package/scripts/gds/autobongos-service.cmd +38 -0
  28. package/scripts/gds/autobongos-service.ps1 +129 -0
  29. package/scripts/gds/deploy-main-audit-gate.sh +90 -0
  30. package/scripts/gds/main-audit.js +26 -4
  31. package/scripts/gds/run-unit-tests.js +16 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/autobongos_cadence.mjs +602 -0
  34. package/tests/autonomy_runner_health.mjs +135 -0
  35. package/tests/bump_version.mjs +12 -1
  36. package/tests/cli_package.mjs +11 -1
  37. package/tests/deploy_audit_gate.mjs +154 -0
  38. package/tests/deploy_divergence_line.mjs +47 -1
  39. package/tests/helpers.mjs +61 -0
  40. package/tests/main_audit_db_roster.mjs +97 -0
  41. package/tests/publish_watch.mjs +9 -0
  42. package/tests/setup_device_flow_fallback.mjs +2 -1
@@ -0,0 +1,135 @@
1
+ // tests/autonomy_runner_health.mjs — a dead runner is visible, not silent (task 1003905).
2
+ //
3
+ // The done-when's second half: "a runner that has stopped heartbeating is visible
4
+ // from the hall." The asymmetry is the whole design — a running machine can say
5
+ // so, a dead one cannot say anything — so the signal is the AGE of the last
6
+ // check-in, never a status the runner writes. These pin the four answers and, more
7
+ // importantly, the three DIFFERENT kinds of quiet.
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { describeRunner, humanAge, ageS, ALIVE_S, LATE_S } = require('../modules/autonomy/runner-health.js');
15
+
16
+ const NOW = Date.parse('2026-09-19T12:00:00.000Z');
17
+ const agoS = (s) => new Date(NOW - s * 1000).toISOString();
18
+ const hb = (over = {}) => ({ host: 'winbox', pid: 4242, mode: 'full', last_seen_at: agoS(30), ...over });
19
+
20
+ test('no row at all is NEVER SEEN, and that is not a failure', () => {
21
+ // Telling an owner their runner "died" before it ever ran is how a status light
22
+ // trains people to ignore it.
23
+ for (const none of [null, undefined, 'nope', 42]) {
24
+ const d = describeRunner(none, { nowMs: NOW });
25
+ assert.equal(d.state, 'never_seen', String(none));
26
+ assert.equal(d.ok, true, 'never set up is not broken');
27
+ assert.match(d.detail, /has ever checked in/);
28
+ }
29
+ });
30
+
31
+ test('the four states follow the age of the last check-in, nothing else', () => {
32
+ assert.equal(describeRunner(hb({ last_seen_at: agoS(10) }), { nowMs: NOW }).state, 'alive');
33
+ assert.equal(describeRunner(hb({ last_seen_at: agoS(ALIVE_S - 1) }), { nowMs: NOW }).state, 'alive');
34
+ assert.equal(describeRunner(hb({ last_seen_at: agoS(ALIVE_S + 1) }), { nowMs: NOW }).state, 'late');
35
+ assert.equal(describeRunner(hb({ last_seen_at: agoS(LATE_S - 1) }), { nowMs: NOW }).state, 'late');
36
+ assert.equal(describeRunner(hb({ last_seen_at: agoS(LATE_S + 1) }), { nowMs: NOW }).state, 'dead');
37
+ assert.equal(describeRunner(hb({ last_seen_at: agoS(86400) }), { nowMs: NOW }).state, 'dead');
38
+ });
39
+
40
+ test('a runner mid-worker goes quiet legitimately, and LATE says so rather than crying dead', () => {
41
+ // A worker may hold the loop for ninety minutes. Reporting every honest hour of
42
+ // work as a corpse is worse than saying nothing, because it teaches the owner to
43
+ // ignore the one that matters.
44
+ const building = describeRunner(hb({ last_seen_at: agoS(70 * 60), working_task_id: '1003905' }), { nowMs: NOW });
45
+ assert.equal(building.state, 'late');
46
+ assert.equal(building.ok, true, 'late is not an alarm');
47
+ assert.match(building.detail, /expected while task 1003905 builds/);
48
+ assert.equal(building.workingTaskId, '1003905');
49
+ });
50
+
51
+ test('a gap longer than any worker takes IS the alarm', () => {
52
+ const d = describeRunner(hb({ last_seen_at: agoS(5 * 3600), working_task_id: '1003905' }), { nowMs: NOW });
53
+ assert.equal(d.state, 'dead');
54
+ assert.equal(d.ok, false);
55
+ assert.match(d.detail, /longer than any worker takes/);
56
+ assert.match(d.detail, /winbox/, 'name the host — an owner with two machines needs to know which');
57
+ });
58
+
59
+ test('an unreadable check-in raises the alarm rather than explaining it away', () => {
60
+ // A row that exists but cannot be read is a runner we heard from once and cannot
61
+ // account for now. On a liveness check the fail direction is to raise the alarm.
62
+ for (const bad of ['', 'not a date', null, undefined]) {
63
+ const d = describeRunner(hb({ last_seen_at: bad }), { nowMs: NOW });
64
+ assert.equal(d.state, 'dead', JSON.stringify(bad));
65
+ assert.equal(d.ok, false);
66
+ }
67
+ // And it is NOT never_seen — the row exists, so something ran once.
68
+ assert.notEqual(describeRunner(hb({ last_seen_at: 'x' }), { nowMs: NOW }).state, 'never_seen');
69
+ });
70
+
71
+ test('a probing runner reads as alive, because probing is working', () => {
72
+ const d = describeRunner(hb({ mode: 'probing', last_seen_at: agoS(20) }), { nowMs: NOW });
73
+ assert.equal(d.state, 'alive');
74
+ assert.equal(d.mode, 'probing');
75
+ assert.match(d.detail, /probing after failures/);
76
+ });
77
+
78
+ test('a clock skew into the future never reads as ancient', () => {
79
+ const d = describeRunner(hb({ last_seen_at: new Date(NOW + 60_000).toISOString() }), { nowMs: NOW });
80
+ assert.equal(d.ageS, 0);
81
+ assert.equal(d.state, 'alive');
82
+ });
83
+
84
+ test('every answer carries a readable age and a sentence', () => {
85
+ for (const s of [0, 30, 300, ALIVE_S + 60, LATE_S + 60, 86400 * 3]) {
86
+ const d = describeRunner(hb({ last_seen_at: agoS(s) }), { nowMs: NOW });
87
+ assert.ok(typeof d.detail === 'string' && d.detail.length, `${s}s needs a sentence`);
88
+ assert.ok(typeof d.age === 'string' && d.age.length, `${s}s needs a readable age`);
89
+ assert.equal(d.ageS, s);
90
+ }
91
+ });
92
+
93
+ test('humanAge reads like a person wrote it', () => {
94
+ assert.equal(humanAge(45), '45s');
95
+ assert.equal(humanAge(300), '5m');
96
+ assert.equal(humanAge(7200), '2h');
97
+ assert.equal(humanAge(86400 * 3), '3d');
98
+ assert.equal(humanAge(NaN), 'unknown');
99
+ });
100
+
101
+ test('ageS refuses to invent a number it cannot compute', () => {
102
+ assert.equal(ageS('nope', NOW), null);
103
+ assert.equal(ageS(agoS(10), NaN), null);
104
+ assert.equal(ageS(agoS(10), NOW), 10);
105
+ });
106
+
107
+ // ── the forgery the grader found (task 1003905) ─────────────────────────────────
108
+
109
+ test('a heartbeat row is keyed by the CALLER, so liveness cannot be forged', async () => {
110
+ // THE ATTACK: with `host` alone as the primary key, any authenticated builder
111
+ // could POST a heartbeat naming the real runner's hostname and keep the signal
112
+ // reading "alive" forever — defeating the one property this table exists for,
113
+ // that a dead machine holding broad spawn permissions becomes visible.
114
+ //
115
+ // An ownership check in the handler would also close it, and would be one `if`
116
+ // away from removal by a later edit that looked reasonable. The caller is in the
117
+ // KEY instead, so the forgery is impossible rather than merely checked for.
118
+ const fs = require('node:fs');
119
+ const path = require('node:path');
120
+ const sql = fs.readFileSync(path.join(process.cwd(), 'modules/autonomy/migrations/autonomy_002_runner_heartbeat.sql'), 'utf8');
121
+ assert.match(sql, /PRIMARY KEY \(builder_id, host\)/, 'the caller must be half the key');
122
+ assert.doesNotMatch(sql, /host\s+text PRIMARY KEY/, 'host alone as the key is the forgeable shape');
123
+
124
+ const db = fs.readFileSync(path.join(process.cwd(), 'modules/autonomy/db.js'), 'utf8');
125
+ assert.match(db, /ON CONFLICT \(builder_id, host\)/, 'the upsert must collide per builder, not per host');
126
+ assert.match(db, /readRunnerHeartbeats\(builderId\)/, 'the read is scoped to one builder');
127
+ assert.match(db, /WHERE builder_id = \$1/);
128
+
129
+ const routes = fs.readFileSync(path.join(process.cwd(), 'modules/autonomy/routes/autonomy.js'), 'utf8');
130
+ // The identity must come from the SESSION. A `host` from the body is fine; a
131
+ // builder id from the body would hand the attacker the key back.
132
+ assert.match(routes, /builderId: req\.builder\.id/);
133
+ assert.doesNotMatch(routes, /builderId: (b|req\.body)\./, 'never take the caller’s identity from the body');
134
+ assert.match(routes, /readRunnerHeartbeats\(req\.builder\.id\)/);
135
+ });
@@ -16,9 +16,11 @@ import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { createRequire } from 'node:module';
19
+ import { shallowCloneReason } from './helpers.mjs';
19
20
 
20
21
  const require = createRequire(import.meta.url);
21
22
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+
22
24
  const bump = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
23
25
  const { bumpPatch, CONST_RE } = bump;
24
26
  // The LIVE test below reads this repo's real release history (task 1003202).
@@ -347,11 +349,20 @@ test('a hand-bumped branch passes the LIVE check its own PR runs', () => {
347
349
  assert.equal(bump.laneReleasedVersions(subjects).has('1.20.0'), true);
348
350
  });
349
351
 
350
- test('LIVE: the version HEAD declares is one this lane already released', () => {
352
+ test('LIVE: the version HEAD declares is one this lane already released', (t) => {
351
353
  // The steady state the lane depends on: HEAD carries the version set by the
352
354
  // previous release commit, so `taken` is true and the auto-bump fires. If this
353
355
  // ever reads false on a normal main checkout, the lane would try to republish
354
356
  // an existing version.
357
+ //
358
+ // This reads the whole release history, so it cannot run on a shallow clone — a
359
+ // cloud session sees ~46 release commits instead of hundreds and the assertion
360
+ // below fails on the clone, not on the code (task 1004049). Skip with the reason
361
+ // printed, never a silent return: an unrunnable case that reports as a pass is the
362
+ // defect task 1003993 is open on.
363
+ const shallow = shallowCloneReason(ROOT);
364
+ if (shallow) { t.skip(shallow); return; }
365
+
355
366
  const headVersion = /^const CORE_VERSION = '([0-9.]+)';/m
356
367
  .exec(fs.readFileSync(path.join(ROOT, 'src', 'module-api.js'), 'utf8'))[1];
357
368
  const subjects = execFileSync('git', ['log', '--format=%s', 'HEAD'], { encoding: 'utf8', cwd: ROOT }).split('\n');
@@ -19,6 +19,7 @@ import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { spawnSync } from 'node:child_process';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
+ import { childEnv } from './helpers.mjs';
22
23
 
23
24
  const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
24
25
 
@@ -34,8 +35,17 @@ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
34
35
  // rather than inlined twice: this is the third site in the repo to need it (after
35
36
  // tests/cli_sessions.mjs in task 1003760 and tests/cli_surface.mjs in task 1003941),
36
37
  // and every one of them got it half-right first.
38
+ //
39
+ // AND AN INHERITED API BASE IS NOT ISOLATION EITHER (task 1004049). The same
40
+ // shape of hole, one env var over: resolveApiBase() reads the CANONICAL
41
+ // <PREFIX>_API_BASE before any legacy spelling, so the `CLOUDBONGOS_API_BASE:
42
+ // 'http://127.0.0.1:45999'` the verbs below rely on to be a DEAD port is silently
43
+ // shadowed on any box that exports the canonical one — every Bongos session box.
44
+ // The verbs then made real calls to the live instance instead of failing fast
45
+ // against a closed port, which is why this test ran for minutes and timed out
46
+ // rather than failing with something readable. childEnv strips every spelling.
37
47
  function homelessEnv(home, extra = {}) {
38
- return { ...process.env, HOME: home, USERPROFILE: home, HOMEDRIVE: '', HOMEPATH: home, ...extra };
48
+ return childEnv({ HOME: home, USERPROFILE: home, HOMEDRIVE: '', HOMEPATH: home, ...extra });
39
49
  }
40
50
  const BUILDER = path.join(REPO_ROOT, 'scripts', 'gds', 'build-cli-package.js');
41
51
  // Running npm from a test (task 1003529). On Windows `npm` is a .cmd shim: bare
@@ -0,0 +1,154 @@
1
+ // tests/deploy_audit_gate.mjs
2
+ //
3
+ // The deployed marker advances ONLY on success (task 1001426, SEC #863 / ADR 0043).
4
+ //
5
+ // The gate audits what a deploy pulled, so it can only run AFTER the working tree has moved
6
+ // to the new commit. That ordering is what the original row calls the marker-bypass: refuse
7
+ // the deploy, exit, and leave the tree sitting on the refused commit, and the NEXT deploy
8
+ // computes its "previously deployed" SHA from the poison. That delta is then never audited
9
+ // again — and it is already checked out. The gate closes it by putting the tree back.
10
+ //
11
+ // There is no marker FILE on a standalone instance; the marker is the checked-out HEAD.
12
+ // These cases pin that, against a stub auditor so the assertions are about the gate's own
13
+ // control flow and never about rank resolution (covered in tests/main_audit_db_roster.mjs).
14
+ //
15
+ // Run: node --test tests/deploy_audit_gate.mjs
16
+
17
+ import assert from 'node:assert/strict';
18
+ import { test } from 'node:test';
19
+ import { execFileSync, spawnSync } from 'node:child_process';
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const GATE = path.join(ROOT, 'scripts', 'gds', 'deploy-main-audit-gate.sh');
27
+
28
+ // A throwaway repo with two commits, checked out at the second — the state a deploy is in
29
+ // when the gate runs: PREV is what was deployed, HEAD is what was just pulled.
30
+ function makeRepo({ auditor = 'exit 0' } = {}) {
31
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-gate-'));
32
+ const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8' });
33
+ git('init', '-q', '-b', 'main');
34
+ git('config', 'user.email', 'test@example.com');
35
+ git('config', 'user.name', 'Test');
36
+ fs.writeFileSync(path.join(dir, 'a.txt'), 'one\n');
37
+ git('add', '-A'); git('commit', '-q', '-m', 'one');
38
+ const prev = git('rev-parse', 'HEAD').trim();
39
+ fs.writeFileSync(path.join(dir, 'a.txt'), 'two\n');
40
+ git('add', '-A'); git('commit', '-q', '-m', 'two (the pulled commit)');
41
+ const head = git('rev-parse', 'HEAD').trim();
42
+
43
+ if (auditor !== null) {
44
+ const gdsDir = path.join(dir, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
45
+ fs.mkdirSync(gdsDir, { recursive: true });
46
+ fs.writeFileSync(path.join(gdsDir, 'main-audit.js'), `process.${auditor.replace('exit ', 'exit(')});\n`);
47
+ }
48
+ return { dir, prev, head, git };
49
+ }
50
+
51
+ function runGate(repo, env = {}) {
52
+ const r = spawnSync('sh', [GATE], {
53
+ cwd: repo.dir, encoding: 'utf8',
54
+ env: { ...process.env, ...env },
55
+ });
56
+ return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}` };
57
+ }
58
+
59
+ const headOf = (repo) => repo.git('rev-parse', 'HEAD').trim();
60
+
61
+ test('a CLEAN audit leaves the deploy on the new commit and exits 0', () => {
62
+ const repo = makeRepo({ auditor: 'exit 0' });
63
+ try {
64
+ const r = runGate(repo, { OTB_DEPLOY_PREV_SHA: repo.prev });
65
+ assert.equal(r.code, 0, r.out);
66
+ assert.equal(headOf(repo), repo.head, 'a passing gate must not move the tree');
67
+ assert.match(r.out, /exact deploy delta/);
68
+ } finally { fs.rmSync(repo.dir, { recursive: true, force: true }); }
69
+ });
70
+
71
+ test('THE BUG: a REFUSED audit puts the checkout back, so the next deploy re-audits the same delta', () => {
72
+ const repo = makeRepo({ auditor: 'exit 1' });
73
+ try {
74
+ const r = runGate(repo, { OTB_DEPLOY_PREV_SHA: repo.prev });
75
+ assert.equal(r.code, 1, 'a refusal must abort the deploy');
76
+ assert.equal(
77
+ headOf(repo), repo.prev,
78
+ 'the tree must be restored to the previously-deployed SHA. Left advanced, the next '
79
+ + "deploy's OTB_DEPLOY_PREV_SHA is the POISONED commit and its delta is never re-audited",
80
+ );
81
+ assert.match(r.out, /DEPLOY ABORTED/);
82
+ assert.match(r.out, /advances only on success/);
83
+ } finally { fs.rmSync(repo.dir, { recursive: true, force: true }); }
84
+ });
85
+
86
+ test('the range handed to the auditor is the exact deploy delta, not a window', () => {
87
+ // Pinned because the whole row is named for an audit range that could be slipped under.
88
+ const repo = makeRepo({ auditor: null });
89
+ try {
90
+ const gdsDir = path.join(repo.dir, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
91
+ fs.mkdirSync(gdsDir, { recursive: true });
92
+ fs.writeFileSync(path.join(gdsDir, 'main-audit.js'),
93
+ 'require("fs").writeFileSync("range.txt", process.env.OTB_AUDIT_RANGE || "UNSET");\n');
94
+ const r = runGate(repo, { OTB_DEPLOY_PREV_SHA: repo.prev });
95
+ assert.equal(r.code, 0, r.out);
96
+ assert.equal(
97
+ fs.readFileSync(path.join(repo.dir, 'range.txt'), 'utf8'), `${repo.prev}..HEAD`,
98
+ 'the auditor must receive <previously-deployed>..HEAD',
99
+ );
100
+ } finally { fs.rmSync(repo.dir, { recursive: true, force: true }); }
101
+ });
102
+
103
+ test('with no previous SHA there is nothing to restore to, and the gate says so', () => {
104
+ // The degraded mode: still audits (wide window), still refuses, but cannot undo the move.
105
+ // It must not pretend otherwise — a silent non-restore is the bug wearing a disguise.
106
+ const repo = makeRepo({ auditor: 'exit 1' });
107
+ try {
108
+ const r = runGate(repo, {});
109
+ assert.equal(r.code, 1);
110
+ assert.equal(headOf(repo), repo.head, 'nothing to restore to');
111
+ assert.match(r.out, /OTB_DEPLOY_PREV_SHA not set/);
112
+ assert.match(r.out, /cannot put the tree back/);
113
+ } finally { fs.rmSync(repo.dir, { recursive: true, force: true }); }
114
+ });
115
+
116
+ test('OTB_GATE_NO_RESTORE=1 leaves the tree to a caller that restores it itself', () => {
117
+ const repo = makeRepo({ auditor: 'exit 1' });
118
+ try {
119
+ const r = runGate(repo, { OTB_DEPLOY_PREV_SHA: repo.prev, OTB_GATE_NO_RESTORE: '1' });
120
+ assert.equal(r.code, 1);
121
+ assert.equal(headOf(repo), repo.head, 'the opt-out must actually opt out');
122
+ } finally { fs.rmSync(repo.dir, { recursive: true, force: true }); }
123
+ });
124
+
125
+ test('a missing auditor FAILS CLOSED — it never waves the deploy through', () => {
126
+ // The failure mode that would quietly disarm every instance: upgrade the core, lose the
127
+ // script, and a gate that treated "not found" as "nothing to report" would pass forever.
128
+ const repo = makeRepo({ auditor: null });
129
+ try {
130
+ const r = runGate(repo, { OTB_DEPLOY_PREV_SHA: repo.prev });
131
+ assert.equal(r.code, 1, 'no auditor must abort the deploy, not permit it');
132
+ assert.match(r.out, /main-audit\.js not found/);
133
+ } finally { fs.rmSync(repo.dir, { recursive: true, force: true }); }
134
+ });
135
+
136
+ test('OTB_AUDIT_STRICT=1 is passed through, and is OFF by default', () => {
137
+ const repo = makeRepo({ auditor: null });
138
+ try {
139
+ const gdsDir = path.join(repo.dir, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
140
+ fs.mkdirSync(gdsDir, { recursive: true });
141
+ fs.writeFileSync(path.join(gdsDir, 'main-audit.js'),
142
+ 'require("fs").writeFileSync("argv.txt", process.argv.slice(2).join(" "));\n');
143
+
144
+ runGate(repo, { OTB_DEPLOY_PREV_SHA: repo.prev });
145
+ assert.equal(
146
+ fs.readFileSync(path.join(repo.dir, 'argv.txt'), 'utf8').includes('--strict'), false,
147
+ 'strict must default OFF: a lane committing under display names resolves to nothing, '
148
+ + 'so strict would block every deploy forever',
149
+ );
150
+
151
+ runGate(repo, { OTB_DEPLOY_PREV_SHA: repo.prev, OTB_AUDIT_STRICT: '1' });
152
+ assert.match(fs.readFileSync(path.join(repo.dir, 'argv.txt'), 'utf8'), /--strict/);
153
+ } finally { fs.rmSync(repo.dir, { recursive: true, force: true }); }
154
+ });
@@ -23,11 +23,13 @@ import { spawnSync } from 'node:child_process';
23
23
  import { createRequire } from 'node:module';
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
+ import { childEnv, API_BASE_ENV_PREFIXES } from './helpers.mjs';
26
27
  import { fileURLToPath } from 'node:url';
27
28
 
28
29
  const require = createRequire(import.meta.url);
29
30
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
31
  const { line } = require('../scripts/gds/deploy-divergence.js');
32
+ const ic = require('../src/instance-config.js');
31
33
  const SCRIPT = path.join(ROOT, 'scripts/gds/deploy-divergence.js');
32
34
 
33
35
  // Run `--line` exactly as SessionStart does. Returns {status, stdout} instead of
@@ -36,7 +38,7 @@ function runLine(env = {}) {
36
38
  const res = spawnSync(
37
39
  process.execPath,
38
40
  [SCRIPT, '--line', '--timeout-ms', '2500'],
39
- { cwd: ROOT, encoding: 'utf8', timeout: 30000, env: { ...process.env, ...env } },
41
+ { cwd: ROOT, encoding: 'utf8', timeout: 30000, env: childEnv(env) },
40
42
  );
41
43
  return { status: res.status, stdout: String(res.stdout || '').trim(), stderr: String(res.stderr || '') };
42
44
  }
@@ -101,6 +103,50 @@ test('--line exits 0 and stays silent when the instance is unreachable', () => {
101
103
  assert.equal(r.stdout, '', 'an unreachable instance must print NOTHING to stdout');
102
104
  });
103
105
 
106
+ test('childEnv strips EVERY spelling of the api base, so an offline stub cannot be shadowed', () => {
107
+ // THE BUG THIS PINS (task 1004049). The case above sets only LEGACY spellings.
108
+ // resolveApiBase() reads the canonical `<PREFIX>_API_BASE` FIRST, so on any box
109
+ // that exports it — every Bongos session box — the legacy stub was silently
110
+ // shadowed and `--line` went and read the LIVE instance. The assertion then
111
+ // compared production's real divergence banner against '' and failed. It passed in
112
+ // CI, where the canonical var is unset, which is why it survived: the test was only
113
+ // ever in control on the one machine nobody was debugging on.
114
+ //
115
+ // This is asserted against childEnv directly rather than by spawning --line at a
116
+ // real host, and the reason is the subject of this very task: an end-to-end version
117
+ // would pass or fail according to whether production happened to be mid-release at
118
+ // the time, which is another ambient dependency. The mechanism is what must hold,
119
+ // so the mechanism is what is pinned.
120
+ // The stub prefix is TAKEN FROM the imported list rather than typed, for the same
121
+ // single-source reason childEnv imports it — and because the repo's rename ratchet
122
+ // (goal 1000073 C5) counts a typed legacy prefix as a new literal.
123
+ const STUB = ic.LEGACY_ENV_PREFIXES.at(-1);
124
+ const STUB_VAR = `${STUB}_API_BASE`;
125
+ const DEAD = 'http://127.0.0.1:9';
126
+
127
+ // Every OTHER spelling points at a host that would answer — the shape of a real
128
+ // session box — so a survivor is the difference between offline and not.
129
+ const box = { PATH: process.env.PATH };
130
+ for (const prefix of API_BASE_ENV_PREFIXES) box[`${prefix}_API_BASE`] = 'https://example.invalid';
131
+
132
+ const out = childEnv({ [STUB_VAR]: DEAD }, { base: box });
133
+
134
+ for (const prefix of API_BASE_ENV_PREFIXES.filter((p) => p !== STUB)) {
135
+ assert.equal(out[`${prefix}_API_BASE`], undefined,
136
+ `${prefix}_API_BASE survived — it would shadow the stub the test set`);
137
+ }
138
+ assert.equal(out[STUB_VAR], DEAD, 'the override the test set must survive');
139
+ assert.equal(out.PATH, process.env.PATH, 'unrelated environment must be passed through untouched');
140
+
141
+ // The list is derived from instance-config, not copied here: a prefix added or
142
+ // sunset there must not need an edit in the test suite to keep working.
143
+ assert.ok(API_BASE_ENV_PREFIXES.includes(ic.envPrefix() || ic.FALLBACK_ENV_PREFIX),
144
+ 'the canonical prefix must be among those stripped');
145
+ for (const legacy of ic.LEGACY_ENV_PREFIXES) {
146
+ assert.ok(API_BASE_ENV_PREFIXES.includes(legacy), `legacy prefix ${legacy} must be stripped too`);
147
+ }
148
+ });
149
+
104
150
  test('--line exits 0 against the REAL instance (the 127 regression)', { timeout: 60000 }, () => {
105
151
  // The success path is the one that broke: a live socket to tear down is exactly
106
152
  // what process.exit() raced. Skips only if the network is unavailable, so it
package/tests/helpers.mjs CHANGED
@@ -70,6 +70,8 @@ export function makeSqlAwareClient(handler) {
70
70
  import fs from 'node:fs';
71
71
  import path from 'node:path';
72
72
  import { fileURLToPath } from 'node:url';
73
+ import { createRequire } from 'node:module';
74
+ import { execFileSync } from 'node:child_process';
73
75
 
74
76
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
75
77
 
@@ -434,3 +436,62 @@ export function sqlColumnLines(sql) {
434
436
  // whose NAME contained a scanned word would otherwise read as a column.
435
437
  .filter((l) => l && !/^(CREATE TABLE|CONSTRAINT|CHECK|\))/.test(l));
436
438
  }
439
+
440
+ // childEnv — build the environment for a spawned child so the API base the test
441
+ // SETS is the API base the child USES (task 1004049).
442
+ //
443
+ // The trap this closes: `src/instance-config.js resolveApiBase()` reads the
444
+ // CANONICAL `<PREFIX>_API_BASE` first and only then the legacy spellings. A test
445
+ // that passes `{ ...process.env, <LEGACY>_API_BASE: stub }` is therefore only in
446
+ // control on a box where the canonical var is UNSET. On a box where it is set —
447
+ // which is every Bongos session box, the tooling exports it — the canonical value
448
+ // wins, the test's stub is silently shadowed, and the child talks to the REAL
449
+ // instance.
450
+ //
451
+ // That is not hypothetical. It is how tests/deploy_divergence_line.mjs came to
452
+ // assert "stays silent when the instance is unreachable" while printing a live
453
+ // reading of the production host; how tests/setup_device_flow_fallback.mjs came to
454
+ // verify a token against the real /me instead of its local stub; and why
455
+ // tests/cli_package.mjs ran every verb against production and timed out after
456
+ // minutes instead of failing fast against a closed port (3.7s once fixed). All
457
+ // three passed in CI, where the canonical var is unset, and failed everywhere else.
458
+ //
459
+ // The prefix list is IMPORTED, never copied: instance-config.js is the single
460
+ // source that decides which spellings resolve, so a prefix added or sunset there
461
+ // must not need a matching edit here — a stale copy would reintroduce exactly the
462
+ // silent-shadowing bug this helper exists to prevent.
463
+ const helperRequire = createRequire(import.meta.url);
464
+ const ic = helperRequire(path.join(ROOT, 'src', 'instance-config.js'));
465
+ export const API_BASE_ENV_PREFIXES = [ic.envPrefix(), ic.FALLBACK_ENV_PREFIX, ...ic.LEGACY_ENV_PREFIXES]
466
+ .filter(Boolean)
467
+ .filter((p, i, a) => a.indexOf(p) === i);
468
+
469
+ export function childEnv(overrides = {}, { base = process.env } = {}) {
470
+ const env = { ...base };
471
+ for (const prefix of API_BASE_ENV_PREFIXES) delete env[`${prefix}_API_BASE`];
472
+ return { ...env, ...overrides };
473
+ }
474
+
475
+ // shallowCloneReason — why a history-reading test cannot run HERE, or null (task 1004049).
476
+ //
477
+ // A cloud session clones shallow, so a test that counts release commits or walks a
478
+ // date window sees a truncated log and asserts against a number that was never going
479
+ // to be right. That is not a defect in the code under test and must not read as one.
480
+ //
481
+ // It must not read as a PASS either. Task 1003993 (same goal) is open precisely
482
+ // because sanitize_rebuild.mjs counted two SKIPPED cases as passed and reported
483
+ // 760/760 while its only real coverage never ran. So the contract here is: return a
484
+ // REASON, and let the caller hand it to node:test's t.skip(), which reports the case
485
+ // under `# skipped` where a reader can see it — never a silent early return.
486
+ export function shallowCloneReason(root = ROOT) {
487
+ let shallow = false;
488
+ try {
489
+ shallow = execFileSync('git', ['rev-parse', '--is-shallow-repository'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
490
+ .trim() === 'true';
491
+ } catch {
492
+ return null; // not a git checkout at all: let the test fail honestly rather than skip
493
+ }
494
+ if (!shallow) return null;
495
+ return 'shallow clone — full history is unavailable here, so this case cannot be evaluated '
496
+ + '(git fetch --unshallow to run it)';
497
+ }
@@ -0,0 +1,97 @@
1
+ // tests/main_audit_db_roster.mjs
2
+ //
3
+ // main-audit.js `--db` must be able to REACH the roster (task 1001426).
4
+ //
5
+ // `--db` is the mode the deploy gate uses: the droplet has Postgres but no Bongos session,
6
+ // so it resolves builder ranks straight from the database. It called
7
+ // `require('../../src/bongos/db.js').getBuildersRoster(...)`. That function was carved out
8
+ // of the core db.js into the lifecycle module (goal 1000010's per-domain split) and this
9
+ // caller was not moved with it, so every `--db` run died with
10
+ //
11
+ // main-audit: --db roster read failed (db.getBuildersRoster is not a function)
12
+ //
13
+ // and returned an EMPTY rank map. An empty map is not a neutral outcome for this tool: every
14
+ // author then resolves to null, no blocker can fire, and the audit reports success — the
15
+ // vacuous green this file's own comments warn about for the API path. Non-strict said
16
+ // "clean" for the one reason it exists to catch; strict said BLOCKER on every commit.
17
+ //
18
+ // Two assertions, because the bug needed both to happen: the address must be live, and a
19
+ // failure to read it must not be laundered into an empty map.
20
+ //
21
+ // Run: node --test tests/main_audit_db_roster.mjs
22
+
23
+ import assert from 'node:assert/strict';
24
+ import { test } from 'node:test';
25
+ import path from 'node:path';
26
+ import { createRequire } from 'node:module';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const audit = require('../scripts/gds/main-audit.js');
30
+
31
+ test('the --db roster address is LIVE: that module really exports getBuildersRoster', () => {
32
+ // Resolved from main-audit.js's OWN directory, exactly as the lazy require() inside it
33
+ // does — so this fails for the same reason the gate would, and never for a different one.
34
+ const auditDir = path.dirname(require.resolve('../scripts/gds/main-audit.js'));
35
+ const mod = require(path.resolve(auditDir, audit.ROSTER_DB_MODULE));
36
+ assert.equal(
37
+ typeof mod.getBuildersRoster, 'function',
38
+ `${audit.ROSTER_DB_MODULE} must export getBuildersRoster — main-audit.js --db calls it, `
39
+ + 'and a moved export reads exactly like "no database" at the deploy gate',
40
+ );
41
+ });
42
+
43
+ test('the OLD address is genuinely dead — this is not a cosmetic rename', () => {
44
+ // Kept so the next reader can see the carve actually happened, rather than wondering
45
+ // whether the require was simply a typo that never worked.
46
+ const coreDb = require('../src/bongos/db.js');
47
+ assert.equal(
48
+ typeof coreDb.getBuildersRoster, 'undefined',
49
+ 'src/bongos/db.js no longer owns the roster; if it does again, collapse the two paths '
50
+ + 'rather than leaving main-audit.js pointing at whichever one happens to answer',
51
+ );
52
+ });
53
+
54
+ test('rowsToMap keys ONLY on github_login — a display name must never resolve a rank', () => {
55
+ // #863-followup finding #6. display_name is builder-controlled, so keying it let one
56
+ // builder alias another login's rank. This is also WHY a deploy lane's own commits stay
57
+ // unresolved on a standalone instance: they are authored under a human display name,
58
+ // not a github_login.
59
+ const map = audit.rowsToMap([
60
+ { github_login: 'somebuilder', display_name: 'Someone Else', rank: 'xenos' },
61
+ { display_name: 'Firstname Lastname', rank: 'archon' },
62
+ ]);
63
+ assert.equal(map.somebuilder, 'xenos');
64
+ assert.equal(map['firstname lastname'], undefined, 'a display name must not carry a rank');
65
+ assert.equal(Object.keys(map).length, 1);
66
+ });
67
+
68
+ test('an unreadable --db roster does NOT resolve to an empty map in silence', async () => {
69
+ // The vacuous green, asserted directly. fetchRankMap({useDb:true}) is forced to fail by
70
+ // pointing it at a database that cannot exist, and must then FALL THROUGH to the
71
+ // API/public roster rather than returning {}. Offline (no network in the unit lane) the
72
+ // fallbacks also fail and the map is legitimately empty — so what is pinned here is that
73
+ // the failure was ANNOUNCED on stderr, which is what a gate operator needs in order to
74
+ // know the audit did not actually run.
75
+ const errs = [];
76
+ const realError = console.error;
77
+ console.error = (...a) => errs.push(a.join(' '));
78
+ const realPg = process.env.PGDATABASE;
79
+ process.env.PGDATABASE = 'cloudbongos_no_such_db_for_tests';
80
+ try {
81
+ const map = await audit.fetchRankMap({ useDb: true });
82
+ assert.equal(typeof map, 'object', 'still returns a map rather than throwing at the gate');
83
+ } finally {
84
+ console.error = realError;
85
+ if (realPg === undefined) delete process.env.PGDATABASE; else process.env.PGDATABASE = realPg;
86
+ }
87
+ const said = errs.join('\n');
88
+ assert.match(
89
+ said, /--db roster read (failed|returned NO builders)/,
90
+ 'a --db roster that cannot be read must say so on stderr; silence here is what made '
91
+ + 'the dead require survive — the gate exits 0 and looks like a pass',
92
+ );
93
+ assert.match(
94
+ said, /falling through/,
95
+ 'and it must say it is falling through, so nobody reads the empty map as "roster is empty"',
96
+ );
97
+ });
@@ -10,9 +10,11 @@ import assert from 'node:assert/strict';
10
10
  import path from 'node:path';
11
11
  import { execFileSync } from 'node:child_process';
12
12
  import { createRequire } from 'node:module';
13
+ import { shallowCloneReason } from './helpers.mjs';
13
14
 
14
15
  const require = createRequire(import.meta.url);
15
16
  const ROOT = path.resolve(import.meta.dirname, '..');
17
+
16
18
  const pw = require(path.join(ROOT, 'scripts', 'gds', 'publish-watch.js'));
17
19
 
18
20
  const REL = (v, iso, sha = 'abcdef1234567890') =>
@@ -80,6 +82,13 @@ test('a read that returned nothing must not read as "nothing missing"', () => {
80
82
  });
81
83
 
82
84
  test('LIVE: replays the real 2026-09-13 publish outage from git + the registry', (t) => {
85
+ // Needs real history: a shallow clone's origin/main does not reach back to the
86
+ // incident window, so the log below comes back empty and the count reads 0 (task
87
+ // 1004049). Skipped with the reason stated, for the same reason the unreachable
88
+ // registry below is skipped rather than failed — and never as a silent pass.
89
+ const shallow = shallowCloneReason(ROOT);
90
+ if (shallow) { t.skip(shallow); return; }
91
+
83
92
  // The eight carrier commits exist on main...
84
93
  // Bounded: this runs on every ordinary suite run, so it reads a window around
85
94
  // the incident rather than the whole history.
@@ -25,6 +25,7 @@
25
25
  // Run: node tests/setup_device_flow_fallback.mjs
26
26
 
27
27
  import { strict as assert } from 'node:assert';
28
+ import { childEnv } from './helpers.mjs';
28
29
  import { createRequire } from 'node:module';
29
30
  import { spawn } from 'node:child_process';
30
31
  import http from 'node:http';
@@ -144,7 +145,7 @@ fs.writeFileSync(driverPath, [
144
145
  function redeemInChild(token, home) {
145
146
  return new Promise((resolve) => {
146
147
  const child = spawn(process.execPath, [driverPath, token], {
147
- env: { ...process.env, HOME: home, USERPROFILE: home, GDS_API_BASE: BASE, OTB_API_BASE: BASE },
148
+ env: childEnv({ HOME: home, USERPROFILE: home, GDS_API_BASE: BASE, OTB_API_BASE: BASE }),
148
149
  });
149
150
  let stdout = '', stderr = '';
150
151
  child.stdout.on('data', (c) => { stdout += c; });