@bongos/core 1.24.77 → 1.24.79

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 (48) hide show
  1. package/.bongos-core.json +91 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +12 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +15 -0
  8. package/docs/api/openapi.json +209 -4
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +109 -92
  11. package/docs/copy-registry.json +291 -108
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +2 -0
  14. package/docs/page-readings.json +111 -97
  15. package/docs/recipes/autobongos-runner-setup.md +148 -0
  16. package/docs/recipes/autobongos-windows-host.md +10 -7
  17. package/modules/autonomy/cadence.js +3 -0
  18. package/modules/autonomy/db.js +86 -7
  19. package/modules/autonomy/fence.js +20 -5
  20. package/modules/autonomy/migrations/autonomy_006_runner_device.sql +45 -0
  21. package/modules/autonomy/module.json +2 -1
  22. package/modules/autonomy/routes/autonomy.js +165 -3
  23. package/modules/autonomy/runner-health.js +27 -0
  24. package/modules/autonomy/skills/autobongos-setup/SKILL.md +86 -0
  25. package/modules/hall-ui/public/commands-lib.js +1 -1
  26. package/modules/hall-ui/public/gate.js +12 -5
  27. package/modules/hall-ui/public/runner-button.css +55 -0
  28. package/modules/hall-ui/public/runner-button.js +187 -0
  29. package/modules/hall-ui/public/settings-autobongos.js +349 -63
  30. package/modules/hall-ui/public/settings.html +23 -10
  31. package/modules/hall-ui/records/runner-button.md +16 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +20 -0
  35. package/scripts/bongos/autobongos-host.js +35 -6
  36. package/scripts/bongos/autobongos-run.js +18 -3
  37. package/scripts/bongos/claude-materialize.js +20 -1
  38. package/scripts/bongos/stranded-rows.js +39 -2
  39. package/scripts/hall-preview/server.js +7 -0
  40. package/src/module-api.js +1 -1
  41. package/tests/autobongos_host.mjs +46 -0
  42. package/tests/autonomy_config_idle.mjs +9 -4
  43. package/tests/autonomy_runner_device.mjs +444 -0
  44. package/tests/autonomy_runner_identity.mjs +3 -1
  45. package/tests/claude_materialize.mjs +74 -0
  46. package/tests/hall_runner_button.mjs +467 -0
  47. package/tests/hall_runner_panel_connect.mjs +73 -0
  48. package/tests/stranded_rows.mjs +50 -0
@@ -0,0 +1,444 @@
1
+ // tests/autonomy_runner_device.mjs — each device's own Run switch (task 1005094).
2
+ //
3
+ // Owner direction, 2026-11-09: running Autobongos on a device is ONE button on
4
+ // that device's card, with any cooldown shown on the button. The server half of
5
+ // that is a switch per device, and what must hold about it:
6
+ //
7
+ // · the switch is the LAST layer of the fence, read by the runner as part of
8
+ // the fence it already reads — and a caller that names no device is exactly
9
+ // as fenced as before;
10
+ // · nothing on the device can move it: the two routes that do refuse a
11
+ // runner's own login, and a check-in can only set the value a NEW row is
12
+ // born with;
13
+ // · a device set up the new way starts stopped; one that predates this keeps
14
+ // running;
15
+ // · a press wakes a waiting runner (the fence's updated_at moves);
16
+ // · a resting runner's resume time reaches the card, sanity-checked.
17
+ //
18
+ // The pure pieces are driven directly; the routes are driven as REAL requests
19
+ // through the assembled router with only the auth gates stubbed (the
20
+ // autonomy_builder_scope precedent); the SQL runs against a stubbed pool.
21
+ //
22
+ // DB-free, no network. Run: node --test tests/autonomy_runner_device.mjs
23
+
24
+ import { test } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import { createRequire } from 'node:module';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const req = createRequire(import.meta.url);
32
+ const express = req('express');
33
+ const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
34
+ const fence = req(path.join(ROOT, 'modules/autonomy/fence.js'));
35
+ const cadence = req(path.join(ROOT, 'modules/autonomy/cadence.js'));
36
+ const configIdle = req(path.join(ROOT, 'modules/autonomy/config-idle.js'));
37
+ const health = req(path.join(ROOT, 'modules/autonomy/runner-health.js'));
38
+
39
+ const PROJECT = Object.freeze({
40
+ enabled: true, paused_reason: null, priority_goal_id: null,
41
+ updated_at: '2026-11-01T09:00:00.000Z', updated_by: '3',
42
+ goals: [{ goal_id: 1000118, note: 'bug squash' }],
43
+ });
44
+ const SCOPE = Object.freeze({ goals: [1000118], owner_paused: false, updated_at: '2026-11-02T09:00:00.000Z' });
45
+
46
+ // ── the fence's last layer ───────────────────────────────────────────────────
47
+
48
+ test('a device its builder stopped is fenced off, and the runner is told which stop it is', () => {
49
+ const f = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device: { host: 'ada-laptop', run_requested: false, run_changed_at: '2026-11-03T09:00:00.000Z' } });
50
+ assert.equal(f.enabled, false);
51
+ assert.equal(f.stop_code, 'device_stopped');
52
+ assert.deepEqual(f.device, { host: 'ada-laptop', run_requested: false });
53
+ const d = fence.decideRun({ fence: f, graderBypassed: false });
54
+ assert.equal(d.go, false);
55
+ assert.equal(d.code, 'device_stopped');
56
+ assert.match(d.reason, /press Run/, 'the runner log says how to start it');
57
+ });
58
+
59
+ test('a device that is switched on, or a caller that names no device, is exactly as fenced as before', () => {
60
+ const before = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true });
61
+ for (const device of [null, undefined, { host: 'pc', run_requested: true, run_changed_at: null }]) {
62
+ const f = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device });
63
+ assert.equal(f.enabled, true, JSON.stringify(device));
64
+ assert.deepEqual(f.goals, before.goals);
65
+ assert.equal(f.stop_code, null);
66
+ }
67
+ assert.equal(before.device, null);
68
+ // Only an explicit false stops: a row that somehow lacks the column is not a stop.
69
+ assert.equal(fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device: { host: 'pc' } }).enabled, true);
70
+ });
71
+
72
+ test('the device switch is the LAST reason given: every wider stop is reported first', () => {
73
+ const device = { host: 'pc', run_requested: false };
74
+ const off = fence.effectiveFence({ project: { ...PROJECT, enabled: false, paused_reason: 'night off' }, scope: SCOPE, mayRun: true, device });
75
+ assert.equal(off.stop_code, 'kill_switch');
76
+ assert.equal(fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: false, device }).stop_code, 'not_permitted');
77
+ assert.equal(fence.effectiveFence({ project: PROJECT, scope: { ...SCOPE, owner_paused: true }, mayRun: true, device }).stop_code, 'builder_paused');
78
+ });
79
+
80
+ test('a press of Run moves the fence stamp a waiting runner is watching', () => {
81
+ const pressed = '2026-11-09T12:00:00.000Z';
82
+ const f = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device: { host: 'pc', run_requested: true, run_changed_at: pressed } });
83
+ assert.equal(f.updated_at, pressed, 'later than the project and the picks, so it wins');
84
+ const older = fence.effectiveFence({ project: PROJECT, scope: SCOPE, mayRun: true, device: { host: 'pc', run_requested: true, run_changed_at: '2026-10-01T00:00:00.000Z' } });
85
+ assert.equal(older.updated_at, new Date(SCOPE.updated_at).toISOString(), 'an old press never moves the stamp backwards');
86
+ });
87
+
88
+ test('the runner waits out a device stop as a stop, and nobody is alerted about it', () => {
89
+ const row = { event: 'fenced', code: 'device_stopped', reason: 'this device is stopped' };
90
+ assert.equal(cadence.classifyEvent(row).class, 'stopped', 'a person decided: no failure ladder, no probing');
91
+ assert.equal(configIdle.configIdleCode(row), null, 'the builder’s own Stop is never a setting someone must fix');
92
+ assert.equal(configIdle.trackIdle(null, row, Date.now()), null);
93
+ });
94
+
95
+ // ── what the card is told ────────────────────────────────────────────────────
96
+
97
+ const NOW = Date.parse('2026-11-09T12:00:00.000Z');
98
+ const beat = (over = {}) => ({ host: 'ada-laptop', last_seen_at: '2026-11-09T11:59:30.000Z', last_event: 'loop', run_requested: true, ...over });
99
+
100
+ test('a stopped device that is still checking in reads as stopped, not running and not an alarm', () => {
101
+ const h = health.describeRunner(beat({ run_requested: false, last_event: 'fenced' }), { nowMs: NOW });
102
+ assert.equal(h.state, 'stopped');
103
+ assert.equal(h.ok, true);
104
+ assert.equal(h.runRequested, false);
105
+ assert.match(h.detail, /stopped on ada-laptop/);
106
+ });
107
+
108
+ test('a device stopped mid-task is still the working runner it is until the task ends', () => {
109
+ const h = health.describeRunner(beat({ run_requested: false, working_task_id: 1004711 }), { nowMs: NOW });
110
+ assert.equal(h.state, 'alive');
111
+ assert.equal(h.workingTaskId, '1004711');
112
+ assert.equal(h.runRequested, false, 'and the card can say Stopping');
113
+ });
114
+
115
+ test('silence wins over the switch: a stopped device nobody has heard from is late or dead first', () => {
116
+ assert.equal(health.describeRunner(beat({ run_requested: false, last_seen_at: '2026-11-09T11:00:00.000Z' }), { nowMs: NOW }).state, 'late');
117
+ assert.equal(health.describeRunner(beat({ run_requested: false, last_seen_at: '2026-11-08T00:00:00.000Z' }), { nowMs: NOW }).state, 'dead');
118
+ });
119
+
120
+ test('every state carries the switch and a rest time that is still ahead', () => {
121
+ const resting = health.describeRunner(beat({ last_event: 'hold', hold_until: '2026-11-09T15:40:00.000Z' }), { nowMs: NOW });
122
+ assert.equal(resting.state, 'alive');
123
+ assert.equal(resting.holdUntil, '2026-11-09T15:40:00.000Z');
124
+ assert.equal(resting.runRequested, true);
125
+ assert.equal(health.describeRunner(beat({ hold_until: new Date('2026-11-09T15:40:00.000Z') }), { nowMs: NOW }).holdUntil, '2026-11-09T15:40:00.000Z', 'the database hands back a Date');
126
+ assert.equal(health.describeRunner(beat({ hold_until: '2026-11-09T11:00:00.000Z' }), { nowMs: NOW }).holdUntil, null, 'a rest already over is not a rest');
127
+ assert.equal(health.describeRunner(beat({ hold_until: 'soon' }), { nowMs: NOW }).holdUntil, null);
128
+ assert.equal(health.describeRunner(beat({ run_requested: undefined }), { nowMs: NOW }).runRequested, true, 'a row from before the column reads as switched on');
129
+ });
130
+
131
+ // ── the routes ───────────────────────────────────────────────────────────────
132
+
133
+ const permStub = (key) => {
134
+ const mw = (_req, _res, next) => next();
135
+ mw.permKey = key;
136
+ return mw;
137
+ };
138
+ // Who is calling: an ordinary session of builder 7, or builder 7's runner login.
139
+ let caller = { runner: false, sessionId: 501 };
140
+ const passBuilder = (reqq, _res, next) => {
141
+ reqq.builder = { id: 7, rank: 'metic', runner: caller.runner };
142
+ reqq.gdsSession = { session_id: caller.sessionId, source: caller.runner ? 'runner' : 'web' };
143
+ reqq.rawPermissionKeys = ['autonomy.run'];
144
+ next();
145
+ };
146
+
147
+ function autonomyRouter() {
148
+ const api = req(path.join(ROOT, 'src/module-api.js'));
149
+ const saved = { b: api.requireBuilder, p: api.requirePermission };
150
+ api.requireBuilder = passBuilder;
151
+ api.requirePermission = permStub;
152
+ const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
153
+ delete req.cache[routePath];
154
+ try { return req(routePath)(); } finally { api.requireBuilder = saved.b; api.requirePermission = saved.p; }
155
+ }
156
+
157
+ async function serve(router) {
158
+ const app = express();
159
+ app.use(express.json());
160
+ app.use(attachFail);
161
+ app.use(router);
162
+ const server = app.listen(0);
163
+ await new Promise((r) => server.once('listening', r));
164
+ return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
165
+ }
166
+
167
+ const send = (base, method, p, body) => fetch(`${base}${p}`, {
168
+ method, headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body),
169
+ });
170
+
171
+ function gateOf(router, p, method) {
172
+ const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
173
+ assert.ok(layer, `${method.toUpperCase()} ${p} is registered`);
174
+ return layer.route.stack.map((s) => s.handle.permKey).filter(Boolean);
175
+ }
176
+
177
+ // Swap db functions for one test, and put them back whatever happens.
178
+ async function withDb(stubs, fn) {
179
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
180
+ const api = req(path.join(ROOT, 'src/module-api.js'));
181
+ const saved = Object.fromEntries(Object.keys(stubs).map((k) => [k, db[k]]));
182
+ const savedResolve = api.resolveOptional;
183
+ Object.assign(db, stubs);
184
+ api.resolveOptional = (name) => (name === 'government' ? { effectivePermissions: (keys) => keys, builderHasPermissions: async () => true } : savedResolve(name));
185
+ const s = await serve(autonomyRouter());
186
+ try { await fn(s.base); } finally {
187
+ s.close();
188
+ Object.assign(db, saved);
189
+ api.resolveOptional = savedResolve;
190
+ caller = { runner: false, sessionId: 501 };
191
+ }
192
+ }
193
+
194
+ test('the gates: Run and Stop behind autonomy.run, Remove open to any builder, the setup scripts public', () => {
195
+ const router = autonomyRouter();
196
+ assert.deepEqual(gateOf(router, '/autonomy/runners/:host/run', 'post'), ['autonomy.run']);
197
+ assert.deepEqual(gateOf(router, '/autonomy/runners/:host', 'delete'), [], 'a builder who lost autonomy.run can still clear their own device');
198
+ assert.deepEqual(gateOf(router, '/autonomy/runner-setup.ps1', 'get'), []);
199
+ assert.deepEqual(gateOf(router, '/autonomy/runner-setup.sh', 'get'), []);
200
+ });
201
+
202
+ test('the fence read folds in the named device, and only that builder’s', async () => {
203
+ const asked = [];
204
+ let device = { host: 'ada-laptop', run_requested: false, run_changed_at: '2026-11-09T12:00:00.000Z' };
205
+ await withDb({
206
+ readFence: async () => PROJECT,
207
+ readBuilderScope: async () => SCOPE,
208
+ readRunnerDevice: async (builderId, host) => { asked.push([builderId, host]); return device; },
209
+ }, async (base) => {
210
+ let body = await (await send(base, 'GET', '/autonomy/fence?host=ADA-Laptop')).json();
211
+ assert.equal(body.enabled, false);
212
+ assert.equal(body.stop_code, 'device_stopped');
213
+ assert.deepEqual(asked, [[7, 'ada-laptop']], 'the SESSION builder’s device, by its lowercased name');
214
+
215
+ device = { host: 'ada-laptop', run_requested: true, run_changed_at: '2026-11-09T12:05:00.000Z' };
216
+ body = await (await send(base, 'GET', '/autonomy/fence?host=ada-laptop')).json();
217
+ assert.equal(body.enabled, true);
218
+ assert.equal(body.updated_at, '2026-11-09T12:05:00.000Z');
219
+
220
+ // No device named: no device read at all, and the answer is the builder's fence.
221
+ asked.length = 0;
222
+ body = await (await send(base, 'GET', '/autonomy/fence')).json();
223
+ assert.equal(body.enabled, true);
224
+ assert.equal(body.device, null);
225
+ assert.deepEqual(asked, []);
226
+
227
+ // A name that is not a device name is treated as not named — never a failed read.
228
+ const odd = await send(base, 'GET', `/autonomy/fence?host=${encodeURIComponent('pc; DROP TABLE')}`);
229
+ assert.equal(odd.status, 200);
230
+ assert.deepEqual(asked, []);
231
+
232
+ // A device that has never checked in has no row: no device layer.
233
+ device = null;
234
+ body = await (await send(base, 'GET', '/autonomy/fence?host=new-pc')).json();
235
+ assert.equal(body.enabled, true);
236
+ });
237
+ });
238
+
239
+ test('a runner’s own login cannot dodge its device’s Stop by leaving the name off, or by naming another device', async () => {
240
+ const asked = [];
241
+ let mine = { host: 'ada-laptop', run_requested: false, run_changed_at: '2026-11-09T12:00:00.000Z' };
242
+ await withDb({
243
+ readFence: async () => PROJECT,
244
+ readBuilderScope: async () => SCOPE,
245
+ readRunnerDeviceBySession: async (builderId, sessionId) => { asked.push(['session', builderId, sessionId]); return mine; },
246
+ readRunnerDevice: async (builderId, host) => { asked.push(['host', builderId, host]); return { host, run_requested: true, run_changed_at: null }; },
247
+ }, async (base) => {
248
+ caller = { runner: true, sessionId: 9 };
249
+ for (const query of ['', '?host=ada-laptop', '?host=some-other-device-that-is-running']) {
250
+ asked.length = 0;
251
+ const body = await (await send(base, 'GET', `/autonomy/fence${query}`)).json();
252
+ assert.equal(body.enabled, false, `a stopped device stays stopped (${query || 'no name'})`);
253
+ assert.equal(body.stop_code, 'device_stopped');
254
+ assert.deepEqual(asked, [['session', 7, 9]], 'found by the session the server holds; the name sent is never consulted');
255
+ }
256
+
257
+ // Switched on, it works — by the same lookup.
258
+ mine = { host: 'ada-laptop', run_requested: true, run_changed_at: '2026-11-09T12:05:00.000Z' };
259
+ assert.equal((await (await send(base, 'GET', '/autonomy/fence')).json()).enabled, true);
260
+
261
+ // A runner login that has not checked in yet has not said which device it is:
262
+ // it reads as stopped, never as a caller with no device layer.
263
+ mine = null;
264
+ const fresh = await (await send(base, 'GET', '/autonomy/fence?host=brand-new')).json();
265
+ assert.equal(fresh.enabled, false);
266
+ assert.equal(fresh.stop_code, 'device_stopped');
267
+
268
+ // The builder's own session is still asked by name, and only when it gives one.
269
+ caller = { runner: false, sessionId: 501 };
270
+ asked.length = 0;
271
+ assert.equal((await (await send(base, 'GET', '/autonomy/fence?host=old-box')).json()).enabled, true);
272
+ assert.deepEqual(asked, [['host', 7, 'old-box']]);
273
+ });
274
+ });
275
+
276
+ test('Run and Stop set that one device’s switch, for the caller only', async () => {
277
+ const writes = [];
278
+ await withDb({
279
+ setRunnerRun: async (args) => { writes.push(args); return args.host === 'ada-laptop' ? { host: 'ada-laptop', run_requested: args.run, run_changed_at: '2026-11-09T12:00:00.000Z' } : null; },
280
+ }, async (base) => {
281
+ let r = await send(base, 'POST', '/autonomy/runners/Ada-Laptop/run', { run: true });
282
+ assert.equal(r.status, 200);
283
+ assert.deepEqual(await r.json(), { host: 'ada-laptop', run_requested: true, run_changed_at: '2026-11-09T12:00:00.000Z' });
284
+ r = await send(base, 'POST', '/autonomy/runners/ada-laptop/run', { run: false });
285
+ assert.equal((await r.json()).run_requested, false);
286
+ assert.deepEqual(writes, [{ builderId: 7, host: 'ada-laptop', run: true }, { builderId: 7, host: 'ada-laptop', run: false }]);
287
+
288
+ assert.equal((await send(base, 'POST', '/autonomy/runners/someone-elses-pc/run', { run: true })).status, 404, 'a device that is not the caller’s is simply not found');
289
+ assert.equal((await send(base, 'POST', '/autonomy/runners/ada-laptop/run', {})).status, 400, 'run is required');
290
+ assert.equal((await send(base, 'POST', '/autonomy/runners/ada-laptop/run', { run: 'yes' })).status, 400, 'and must be a boolean');
291
+ assert.equal((await send(base, 'POST', '/autonomy/runners/ada-laptop/run', { run: true, builder_id: 3 })).status, 400, 'no extra field can name another builder');
292
+ });
293
+ });
294
+
295
+ test('a runner’s own login can neither start, stop nor remove itself', async () => {
296
+ let touched = 0;
297
+ await withDb({
298
+ setRunnerRun: async () => { touched += 1; return { host: 'ada-laptop', run_requested: true }; },
299
+ removeRunner: async () => { touched += 1; return { host: 'ada-laptop', session_id: '9' }; },
300
+ }, async (base) => {
301
+ caller = { runner: true, sessionId: 9 };
302
+ for (const [method, p, body] of [['POST', '/autonomy/runners/ada-laptop/run', { run: true }], ['POST', '/autonomy/runners/ada-laptop/run', { run: false }], ['DELETE', '/autonomy/runners/ada-laptop', undefined]]) {
303
+ const r = await send(base, method, p, body);
304
+ assert.equal(r.status, 403, `${method} ${p}`);
305
+ assert.equal((await r.json()).error.code, 'runner_session');
306
+ }
307
+ assert.equal(touched, 0, 'refused before anything was read or written');
308
+ });
309
+ });
310
+
311
+ test('Remove forgets the caller’s device and says whether it had a login of its own', async () => {
312
+ const removed = [];
313
+ await withDb({
314
+ removeRunner: async (args) => { removed.push(args); return args.host === 'ada-laptop' ? { host: 'ada-laptop', session_id: '8801' } : (args.host === 'old-box' ? { host: 'old-box', session_id: null } : null); },
315
+ }, async (base) => {
316
+ assert.deepEqual(await (await send(base, 'DELETE', '/autonomy/runners/ada-laptop')).json(), { removed: true, host: 'ada-laptop', had_own_login: true });
317
+ assert.deepEqual(await (await send(base, 'DELETE', '/autonomy/runners/old-box')).json(), { removed: true, host: 'old-box', had_own_login: false });
318
+ assert.equal((await send(base, 'DELETE', '/autonomy/runners/not-mine')).status, 404);
319
+ assert.deepEqual(removed.map((a) => a.builderId), [7, 7, 7], 'always the session builder');
320
+ });
321
+ });
322
+
323
+ test('a check-in records the rest time and the runner’s own login from the SESSION, and a new device starts stopped', async () => {
324
+ const beats = [];
325
+ await withDb({ recordRunnerHeartbeat: async (args) => { beats.push(args); return { host: args.host }; } }, async (base) => {
326
+ const inTwoHours = Math.floor(Date.now() / 1000) + 7200;
327
+
328
+ // A runner's own login: its session id is recorded, and a new row starts stopped.
329
+ caller = { runner: true, sessionId: 9 };
330
+ let r = await send(base, 'POST', '/autonomy/heartbeat', { host: 'ada-laptop', last_event: 'hold', hold_until: inTwoHours, session_id: 4242 });
331
+ assert.equal(r.status, 400, 'the body cannot name a session');
332
+ r = await send(base, 'POST', '/autonomy/heartbeat', { host: 'ada-laptop', last_event: 'hold', hold_until: inTwoHours });
333
+ assert.equal(r.status, 200);
334
+ assert.equal(beats[0].sessionId, 9, 'from the session that checked in');
335
+ assert.equal(beats[0].startStopped, true);
336
+ assert.equal(Math.round(beats[0].holdUntil.getTime() / 1000), inTwoHours);
337
+
338
+ // The builder's own CLI session (a runner that predates this): no login of its
339
+ // own to revoke, and a new row is born running, as it always was.
340
+ caller = { runner: false, sessionId: 501 };
341
+ await send(base, 'POST', '/autonomy/heartbeat', { host: 'old-box', last_event: 'loop' });
342
+ assert.equal(beats[1].sessionId, null);
343
+ assert.equal(beats[1].startStopped, false);
344
+ assert.equal(beats[1].holdUntil, null);
345
+
346
+ // A rest time that no real rest can have is dropped, never printed on a button.
347
+ for (const bad of [Math.floor(Date.now() / 1000) + 30 * 24 * 3600, 5, -1]) {
348
+ beats.length = 0;
349
+ await send(base, 'POST', '/autonomy/heartbeat', { host: 'old-box', hold_until: bad });
350
+ assert.equal(beats[0].holdUntil, null, String(bad));
351
+ }
352
+ assert.equal((await send(base, 'POST', '/autonomy/heartbeat', { host: 'old-box', hold_until: 'soon' })).status, 400);
353
+ });
354
+ });
355
+
356
+ test('the caller’s devices come back with the switch, the rest time and the server’s verdict', async () => {
357
+ await withDb({
358
+ readRunnerHeartbeats: async () => [
359
+ { host: 'ada-laptop', last_seen_at: new Date().toISOString(), last_event: 'fenced', run_requested: false, run_changed_at: null, hold_until: null, session_id: '8801' },
360
+ { host: 'old-box', last_seen_at: new Date().toISOString(), last_event: 'hold', run_requested: true, run_changed_at: null, hold_until: new Date(Date.now() + 3600 * 1000).toISOString(), session_id: null },
361
+ ],
362
+ }, async (base) => {
363
+ const { runners } = await (await send(base, 'GET', '/autonomy/runners')).json();
364
+ assert.equal(runners[0].health.state, 'stopped');
365
+ assert.equal(runners[0].run_requested, false);
366
+ assert.equal(runners[0].session_id, '8801', 'the caller’s own session id, for Remove');
367
+ assert.equal(runners[1].health.state, 'alive');
368
+ assert.ok(runners[1].health.holdUntil, 'a rest still ahead rides on the verdict');
369
+ });
370
+ });
371
+
372
+ test('the project serves the two first lines of setup to a device with no login', async () => {
373
+ await withDb({}, async (base) => {
374
+ const ps = await send(base, 'GET', '/autonomy/runner-setup.ps1');
375
+ assert.equal(ps.status, 200);
376
+ assert.match(ps.headers.get('content-type'), /text\/plain/);
377
+ assert.equal(ps.headers.get('cache-control'), 'no-store');
378
+ const psText = await ps.text();
379
+ assert.match(psText, /autobongos-host\.js/);
380
+ assert.match(psText, /\$Code/);
381
+
382
+ const sh = await send(base, 'GET', '/autonomy/runner-setup.sh');
383
+ assert.equal(sh.status, 200);
384
+ const shText = await sh.text();
385
+ assert.match(shText, /^#!\/usr\/bin\/env bash/);
386
+ // Nothing about THIS instance is written into either: no address, no code.
387
+ for (const text of [psText, shText]) assert.doesNotMatch(text, /abr_[0-9a-f]{16}/);
388
+ });
389
+ });
390
+
391
+ // ── the SQL ──────────────────────────────────────────────────────────────────
392
+
393
+ // The module's queries, run against a stand-in pool (the doorway's shared pool
394
+ // object, with its query swapped for the length of one test).
395
+ async function withPool(answer, fn) {
396
+ const api = req(path.join(ROOT, 'src/module-api.js'));
397
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
398
+ const queries = [];
399
+ const saved = api.pool.query;
400
+ api.pool.query = async (text, params) => { queries.push({ text, params }); return answer(text, params) || { rows: [] }; };
401
+ try { await fn(db, queries); } finally { api.pool.query = saved; }
402
+ }
403
+
404
+ test('a check-in can set the switch only on a row it creates: the conflict arm never names it', async () => {
405
+ await withPool(() => ({ rows: [{ host: 'pc' }] }), async (db, queries) => {
406
+ await db.recordRunnerHeartbeat({ builderId: 7, host: 'PC', sessionId: 9, startStopped: true, holdUntil: new Date('2026-11-09T15:40:00Z') });
407
+ await db.recordRunnerHeartbeat({ builderId: 7, host: 'old-box' });
408
+ const [fresh, legacy] = queries;
409
+ const [insert, conflict] = fresh.text.split('ON CONFLICT');
410
+ assert.match(insert, /run_requested/);
411
+ assert.doesNotMatch(conflict.split('RETURNING')[0], /run_requested/, 'an existing device keeps the switch its builder set, whatever checks in');
412
+ assert.match(conflict, /hold_until = EXCLUDED\.hold_until/, 'a runner that stopped resting stops saying so');
413
+ assert.match(conflict, /session_id = EXCLUDED\.session_id/);
414
+ assert.equal(fresh.params[0], 'pc', 'the host is the lowercased key');
415
+ assert.equal(fresh.params[15], false, 'a new device added the new way is born stopped');
416
+ assert.equal(legacy.params[15], true, 'a runner on its builder’s own session is born running, as before');
417
+ assert.equal(fresh.params[14], 9);
418
+ assert.equal(legacy.params[14], null);
419
+ });
420
+ });
421
+
422
+ test('the switch and the removal are keyed by the builder AND the device', async () => {
423
+ await withPool((text) => (/^\s*DELETE/.test(text) ? { rows: [{ host: 'pc', session_id: 8801n }] } : { rows: [{ host: 'pc', run_requested: true, run_changed_at: 't' }] }), async (db, queries) => {
424
+ await db.setRunnerRun({ builderId: 7, host: 'PC', run: 'truthy-but-not-true' });
425
+ assert.match(queries[0].text, /WHERE builder_id = \$1 AND host = \$2/);
426
+ assert.match(queries[0].text, /run_changed_at = now\(\)/);
427
+ assert.deepEqual(queries[0].params, [7, 'pc', false], 'only a real true switches it on');
428
+
429
+ const out = await db.removeRunner({ builderId: 7, host: 'PC' });
430
+ assert.match(queries[1].text, /DELETE FROM autonomy_runner_heartbeat WHERE builder_id = \$1 AND host = \$2/);
431
+ assert.deepEqual(out, { host: 'pc', session_id: '8801' });
432
+
433
+ await db.readRunnerDevice(7, 'PC');
434
+ assert.deepEqual(queries[2].params, [7, 'pc']);
435
+ assert.equal(await db.readRunnerDevice(7, ''), null, 'no name, no read');
436
+ assert.equal(queries.length, 3);
437
+
438
+ await db.readRunnerDeviceBySession(7, 9);
439
+ assert.match(queries[3].text, /WHERE builder_id = \$1 AND session_id = \$2/);
440
+ assert.deepEqual(queries[3].params, [7, 9]);
441
+ assert.equal(await db.readRunnerDeviceBySession(7, null), null, 'no session, no read');
442
+ assert.equal(queries.length, 4);
443
+ });
444
+ });
@@ -189,7 +189,9 @@ test('the heartbeat SQL overwrites the identity every beat and reads it back', a
189
189
  for (const col of ['head', 'disk_head', 'main_head', 'claude_account']) {
190
190
  assert.match(calls[0].sql, new RegExp(`\\b${col} = EXCLUDED\\.${col}\\b`), `${col} is overwritten, never kept from the last process`);
191
191
  }
192
- assert.deepEqual(calls[0].params.slice(9), [A, B, B, 'me@example.com']);
192
+ // The four identity values, in their own places; what follows them is the
193
+ // device's rest time, login and birth switch (task 1005094).
194
+ assert.deepEqual(calls[0].params.slice(9, 13), [A, B, B, 'me@example.com']);
193
195
  await db.readRunnerHeartbeats(3);
194
196
  assert.match(calls[1].sql, /head, disk_head, main_head, claude_account/);
195
197
  } finally {
@@ -951,6 +951,69 @@ t("the core's OWN deny list covers every file the CLI reads a session from, and
951
951
  }
952
952
  });
953
953
 
954
+ // ---- task 1005186: the runner's own login file is denied like the builder's ----
955
+ // Task 1005095 moved an Autobongos runner's session, heartbeat and run log under a folder of its
956
+ // own, so every deny rule above (all under ~/.config) stopped short of it and a worker on a
957
+ // runner device could read the runner's login.
958
+ const hostProgram = require('../scripts/bongos/autobongos-host.js');
959
+ const RUNNER_RULE = 'Read(~/.cloudbongos-runner/config/**)';
960
+
961
+ t('the runner deny rule names the folder the device program actually uses, however the runner was installed', () => {
962
+ // Asked of the device program with a stand-in home, so a renamed folder fails HERE rather
963
+ // than leaving a rule that guards a place nothing writes to.
964
+ const home = join(tmpdir(), 'runner-deny-home');
965
+ const ruleFor = (env) => `Read(~/${relative(home, hostProgram.hostPaths(env, home).configRoot).split(sep).join('/')}/**)`;
966
+ assert.equal(ruleFor({}), RUNNER_RULE, 'the default install');
967
+ // AUTOBONGOS_HOME moves the runner's copy, logs and lock. It must NOT move the login:
968
+ // a login an environment variable can relocate is one the rule does not cover.
969
+ for (const custom of [join(home, 'elsewhere'), join(tmpdir(), 'not-even-under-home'), join(home, '.cloudbongos-runner')]) {
970
+ const env = { AUTOBONGOS_HOME: custom };
971
+ assert.equal(ruleFor(env), RUNNER_RULE, `AUTOBONGOS_HOME=${custom}`);
972
+ assert.equal(hostProgram.hostPaths(env, home).root, custom, 'while the rest of the runner does move');
973
+ }
974
+ });
975
+
976
+ t('the runner deny rule covers the files a runner keeps there: its session, the store, its heartbeat', () => {
977
+ // The same cli-lib and supervisor a runner runs, under the runner's config root.
978
+ const home = join(tmpdir(), 'runner-deny-home');
979
+ const root = hostProgram.hostPaths({}, home).configRoot;
980
+ const probe = `
981
+ const cli = require(${JSON.stringify(join(ROOT, 'scripts', 'bongos', 'cli-lib.js'))});
982
+ const run = require(${JSON.stringify(join(ROOT, 'scripts', 'bongos', 'autobongos-run.js'))});
983
+ process.stdout.write(JSON.stringify([...cli.SESSION_READ_PATHS, cli.sessionStoreDir(), run.heartbeatPath(), run.logPath()]));`;
984
+ const r = require('node:child_process').spawnSync(process.execPath, ['-e', probe], { encoding: 'utf8', env: { ...process.env, BONGOS_CONFIG_ROOT: root, AUTOBONGOS_HEARTBEAT_FILE: '', AUTOBONGOS_LOG_FILE: '' } });
985
+ assert.equal(r.status, 0, r.stderr);
986
+ const files = JSON.parse(r.stdout);
987
+ assert.ok(files.length >= 5);
988
+ const guarded = `${root}${sep}`; // what Read(<root>/**) matches
989
+ for (const f of files) assert.ok(f.startsWith(guarded), `${f} sits outside the denied folder`);
990
+ });
991
+
992
+ t("the core's own deny list and the starter both carry the runner rule", () => {
993
+ const core = JSON.parse(readFileSync(join(ROOT, '.claude', 'settings.json'), 'utf8')).permissions.deny;
994
+ assert.ok(core.includes(RUNNER_RULE), 'the core checkout is itself where a runner works');
995
+ assert.ok(starterDenyFor({}).includes(RUNNER_RULE));
996
+ assert.ok(starterDenyFor({ configDir: 'acme', envPrefix: 'ACME' }).includes(RUNNER_RULE), 'at the same fixed place whatever the instance is called');
997
+ });
998
+
999
+ t('a checkout-materialized instance keeps the runner rule at its fixed place through the rebrand', () => {
1000
+ // An instance whose own name is the word in the folder's name: the rebrand must not
1001
+ // carry the rule off to a folder no runner uses.
1002
+ for (const branding of [{ configDir: 'acme', envPrefix: 'ACME' }, { configDir: 'cloudbongos' }, { configDir: 'otb', envPrefix: 'OTB' }]) {
1003
+ const core = mkdtempSync(join(tmpdir(), 'w3-runner-core-'));
1004
+ const inst = mkdtempSync(join(tmpdir(), 'w3-runner-inst-'));
1005
+ mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
1006
+ writeFileSync(join(core, '.claude', 'settings.json'), JSON.stringify({ permissions: { deny: [RUNNER_RULE] } }));
1007
+ mkdirSync(join(inst, 'config'), { recursive: true });
1008
+ writeFileSync(join(inst, 'config', 'branding.json'), JSON.stringify(branding));
1009
+ m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
1010
+ const deny = JSON.parse(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8')).permissions.deny;
1011
+ assert.deepEqual(deny, [RUNNER_RULE], JSON.stringify(branding));
1012
+ rmSync(core, { recursive: true, force: true });
1013
+ rmSync(inst, { recursive: true, force: true });
1014
+ }
1015
+ });
1016
+
954
1017
  t('a checkout-materialized instance keeps the session-store rule at its fixed anchor', () => {
955
1018
  // The rebrand rewrites every `.config/<core dir>` literal, and the store's dir is one of them —
956
1019
  // but the store does not move with the brand, so a rebranded rule guards a dir nothing reads.
@@ -1011,6 +1074,17 @@ t('an instance still on the allow-only starter GAINS the deny rules on its next
1011
1074
  f.cleanup();
1012
1075
  });
1013
1076
 
1077
+ t('an instance that already has a deny list gains the runner rule at its next upgrade (task 1005186)', () => {
1078
+ // Every rule it had before this task, and not the new one.
1079
+ const had = topUpDenyFor({ configDir: 'acme' }).filter((r) => r !== RUNNER_RULE);
1080
+ const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW, deny: had } });
1081
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
1082
+ const after = JSON.parse(f.read());
1083
+ assert.deepEqual(res.settingsDenyAdded, [RUNNER_RULE], 'the one rule it was missing, and it is named');
1084
+ assert.deepEqual(after.permissions.deny, [...had, RUNNER_RULE], 'appended; nothing it had is moved or dropped');
1085
+ f.cleanup();
1086
+ });
1087
+
1014
1088
  t("an owner-edited settings.json keeps every rule of its own and only gains what's missing", () => {
1015
1089
  const f = topUpFixture({
1016
1090
  permissions: { allow: ['Bash(npm test:*)'], deny: ['Read(secrets/**)', 'Read(**/*.pem)'], ask: ['Bash(rm:*)'] },