@bongos/core 1.24.76 → 1.24.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +129 -49
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +45 -0
- package/docs/api/openapi.json +209 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +109 -92
- package/docs/copy-registry.json +291 -108
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +111 -97
- package/docs/recipes/autobongos-runner-setup.md +148 -0
- package/docs/recipes/autobongos-windows-host.md +10 -7
- package/modules/autonomy/cadence.js +3 -0
- package/modules/autonomy/db.js +86 -7
- package/modules/autonomy/fence.js +20 -5
- package/modules/autonomy/gauge.js +25 -1
- package/modules/autonomy/migrations/autonomy_006_runner_device.sql +45 -0
- package/modules/autonomy/module.json +2 -1
- package/modules/autonomy/routes/autonomy.js +165 -3
- package/modules/autonomy/runner-health.js +27 -0
- package/modules/autonomy/skills/autobongos-setup/SKILL.md +86 -0
- package/modules/hall-ui/public/commands-lib.js +1 -1
- package/modules/hall-ui/public/gate.js +12 -5
- package/modules/hall-ui/public/runner-button.css +55 -0
- package/modules/hall-ui/public/runner-button.js +187 -0
- package/modules/hall-ui/public/settings-autobongos.js +349 -63
- package/modules/hall-ui/public/settings.html +23 -10
- package/modules/hall-ui/records/runner-button.md +16 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/autobongos-bootstrap.ps1 +169 -0
- package/scripts/bongos/autobongos-bootstrap.sh +151 -0
- package/scripts/bongos/autobongos-host.js +883 -0
- package/scripts/bongos/autobongos-run.js +25 -4
- package/scripts/bongos/autonomy-gauge.js +11 -3
- package/scripts/bongos/cli-lib.js +5 -1
- package/scripts/bongos/stranded-rows.js +39 -2
- package/scripts/gds/autobongos-bootstrap.ps1 +8 -0
- package/scripts/gds/autobongos-bootstrap.sh +8 -0
- package/scripts/gds/autobongos-host.js +7 -0
- package/scripts/hall-preview/server.js +7 -0
- package/src/instance-config.js +22 -3
- package/src/module-api.js +1 -1
- package/tests/autobongos_host.mjs +647 -0
- package/tests/autobongos_loop.mjs +11 -0
- package/tests/autonomy_config_idle.mjs +9 -4
- package/tests/autonomy_gauge.mjs +43 -0
- package/tests/autonomy_runner_device.mjs +444 -0
- package/tests/autonomy_runner_identity.mjs +3 -1
- package/tests/cli_sessions.mjs +16 -0
- package/tests/hall_runner_button.mjs +467 -0
- package/tests/hall_runner_panel_connect.mjs +73 -0
- package/tests/stranded_rows.mjs +50 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// tests/autobongos_host.mjs — the runner's device program
|
|
2
|
+
// (scripts/bongos/autobongos-host.js, task 1005095).
|
|
3
|
+
//
|
|
4
|
+
// What must hold, each driven through the real code rather than read off it:
|
|
5
|
+
// · the outer loop relaunches at once on the supervisor's upgrade exit, waits
|
|
6
|
+
// longer after each crash, and ends on a stop request;
|
|
7
|
+
// · it never resets a checkout that is not marked as the runner's own;
|
|
8
|
+
// · stop WATCHES real processes exit before saying so, and says NOT STOPPED
|
|
9
|
+
// when one outlives the wait (the lie task 1004375 was filed for);
|
|
10
|
+
// · the runner's login lives under its own config root, where the builder's
|
|
11
|
+
// own session can neither be overwritten nor fallen back to;
|
|
12
|
+
// · the two start entries (a Windows Run value, a Mac launchd agent) are built
|
|
13
|
+
// for any path a real device has, spaces and all.
|
|
14
|
+
//
|
|
15
|
+
// Every case runs on every platform: the platform is an argument, so the Mac
|
|
16
|
+
// entry is exercised on Windows and Linux and the Windows one on a Mac.
|
|
17
|
+
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
21
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
22
|
+
import { tmpdir, hostname } from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const host = require('../scripts/bongos/autobongos-host.js');
|
|
29
|
+
const HOST_JS = fileURLToPath(new URL('../scripts/bongos/autobongos-host.js', import.meta.url));
|
|
30
|
+
const REPO = fileURLToPath(new URL('..', import.meta.url));
|
|
31
|
+
|
|
32
|
+
const tmp = mkdtempSync(path.join(tmpdir(), 'ab-host-'));
|
|
33
|
+
process.on('exit', () => { try { rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
|
34
|
+
const fresh = (name) => { const d = path.join(tmp, name); mkdirSync(d, { recursive: true }); return d; };
|
|
35
|
+
|
|
36
|
+
// A loop harness: `exits` is what each supervisor run returns, in order.
|
|
37
|
+
function loopDeps(exits, extra = {}) {
|
|
38
|
+
const calls = { refresh: 0, deps: 0, spawns: 0, sleeps: [], logs: [] };
|
|
39
|
+
let t = 0;
|
|
40
|
+
return {
|
|
41
|
+
calls,
|
|
42
|
+
deps: {
|
|
43
|
+
refresh: async () => { calls.refresh += 1; },
|
|
44
|
+
ensureDeps: async () => { calls.deps += 1; },
|
|
45
|
+
spawnSupervisor: async () => { calls.spawns += 1; t += extra.runMs || 1000; return exits.shift(); },
|
|
46
|
+
sleep: async (ms) => { calls.sleeps.push(ms / 1000); },
|
|
47
|
+
log: (l) => calls.logs.push(l),
|
|
48
|
+
now: () => t,
|
|
49
|
+
...extra,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── the outer loop ──────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
test('the upgrade exit code is the supervisor\'s own', () => {
|
|
57
|
+
const run = require('../scripts/bongos/autobongos-run.js');
|
|
58
|
+
assert.equal(host.UPGRADE_EXIT_CODE, run.UPGRADE_EXIT_CODE);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an upgrade exit relaunches at once, refreshing the copy first, with no wait', async () => {
|
|
62
|
+
const { calls, deps } = loopDeps([75, 75, 75]);
|
|
63
|
+
const out = await host.runLoop({ maxCycles: 3 }, deps);
|
|
64
|
+
assert.equal(out, 75);
|
|
65
|
+
assert.equal(calls.spawns, 3);
|
|
66
|
+
assert.equal(calls.refresh, 3, 'every launch brings the copy current first');
|
|
67
|
+
assert.equal(calls.deps, 3, 'and checks its dependencies');
|
|
68
|
+
assert.deepEqual(calls.sleeps, [], 'an upgrade is not a failure, so nothing waits');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a crash waits before the next launch, and the wait grows to a ceiling', async () => {
|
|
72
|
+
const { calls, deps } = loopDeps(new Array(9).fill(1));
|
|
73
|
+
await host.runLoop({ maxCycles: 9 }, deps);
|
|
74
|
+
assert.deepEqual(calls.sleeps, [5, 10, 20, 40, 80, 160, 300, 300], 'doubling from 5s, capped at 5 minutes');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a run that stayed up resets the wait, so one crash a day is not treated as a storm', async () => {
|
|
78
|
+
const { calls, deps } = loopDeps([1, 1, 1], { runMs: host.HEALTHY_RUN_MS });
|
|
79
|
+
await host.runLoop({ maxCycles: 3 }, deps);
|
|
80
|
+
assert.deepEqual(calls.sleeps, [5, 5]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an upgrade exit after crashes clears the backoff', async () => {
|
|
84
|
+
const { calls, deps } = loopDeps([1, 1, 75, 1, 1]);
|
|
85
|
+
await host.runLoop({ maxCycles: 5 }, deps);
|
|
86
|
+
assert.deepEqual(calls.sleeps, [5, 10, 5]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a stop request ends the loop instead of relaunching', async () => {
|
|
90
|
+
let stop = false;
|
|
91
|
+
const { calls, deps } = loopDeps([1, 1], { shouldStop: () => stop });
|
|
92
|
+
deps.spawnSupervisor = async () => { calls.spawns += 1; stop = true; return 1; };
|
|
93
|
+
assert.equal(await host.runLoop({}, deps), 'stopped');
|
|
94
|
+
assert.equal(calls.spawns, 1);
|
|
95
|
+
assert.deepEqual(calls.sleeps, []);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('when this program itself changed, an upgrade exit hands over instead of looping on old code', async () => {
|
|
99
|
+
const { calls, deps } = loopDeps([75], { selfChanged: () => true });
|
|
100
|
+
assert.equal(await host.runLoop({}, deps), 'relaunch_self');
|
|
101
|
+
assert.equal(calls.spawns, 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a failed refresh or dependency install does not stop the runner starting', async () => {
|
|
105
|
+
const { calls, deps } = loopDeps([75]);
|
|
106
|
+
deps.refresh = async () => { throw new Error('offline'); };
|
|
107
|
+
deps.ensureDeps = async () => { throw new Error('npm ci exited 1'); };
|
|
108
|
+
assert.equal(await host.runLoop({ maxCycles: 1 }, deps), 75);
|
|
109
|
+
assert.equal(calls.spawns, 1, 'being a commit behind beats not running at all');
|
|
110
|
+
assert.ok(calls.logs.some((l) => /offline/.test(l)) && calls.logs.some((l) => /npm ci/.test(l)), 'and both are in the log');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── the runner's own copy ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
test('a checkout is the runner\'s own only when it carries the mark, and a linked worktree never does', () => {
|
|
116
|
+
const clone = fresh('clone');
|
|
117
|
+
mkdirSync(path.join(clone, '.git'));
|
|
118
|
+
assert.equal(host.isRunnerCopy(clone), false);
|
|
119
|
+
assert.throws(() => host.assertRunnerCopy(clone), (e) => e.name === 'Stop' && /not the runner's own copy/.test(e.message) && /Settings/.test(e.fix));
|
|
120
|
+
writeFileSync(host.runnerCopyMarkPath(clone), 'marked\n');
|
|
121
|
+
assert.equal(host.isRunnerCopy(clone), true);
|
|
122
|
+
assert.doesNotThrow(() => host.assertRunnerCopy(clone));
|
|
123
|
+
|
|
124
|
+
const linked = fresh('linked');
|
|
125
|
+
writeFileSync(path.join(linked, '.git'), 'gitdir: /somewhere/else\n'); // what a linked worktree has
|
|
126
|
+
assert.equal(host.isRunnerCopy(linked), false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('run, start and install refuse a working copy with a sentence, and change nothing', () => {
|
|
130
|
+
// This checkout is a builder's (or CI's) working copy: it has no mark.
|
|
131
|
+
const home = fresh('refuse-home');
|
|
132
|
+
for (const cmd of ['run', 'start', 'install']) {
|
|
133
|
+
const r = spawnSync(process.execPath, [HOST_JS, cmd], { encoding: 'utf8', env: { ...process.env, AUTOBONGOS_HOME: home } });
|
|
134
|
+
assert.equal(r.status, 1, `${cmd} must refuse`);
|
|
135
|
+
assert.match(r.stderr, /Autobongos stopped here: .*not the runner's own copy/, cmd);
|
|
136
|
+
assert.match(r.stderr, /What to do: /, cmd);
|
|
137
|
+
assert.doesNotMatch(r.stderr, /\n\s+at /, `${cmd} must not print a stack`);
|
|
138
|
+
}
|
|
139
|
+
assert.equal(existsSync(path.join(home, 'host.json')), false, 'no lock was taken');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── one loop per device ─────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
test('the lock is held only by a fresh beat from a live process that is not us', () => {
|
|
145
|
+
const now = 1_000_000;
|
|
146
|
+
const alive = () => true;
|
|
147
|
+
assert.equal(host.lockHolder(null, { now, alive }), null);
|
|
148
|
+
assert.equal(host.lockHolder({ pid: 4242, at: now - 1000 }, { now, alive, self: 1 }), 4242);
|
|
149
|
+
assert.equal(host.lockHolder({ pid: 4242, at: now - 1000 }, { now, alive, self: 4242 }), null, 'our own beat');
|
|
150
|
+
assert.equal(host.lockHolder({ pid: 4242, at: now - 1000 }, { now, alive, self: 1, takeover: 4242 }), null, 'the copy handing over to us');
|
|
151
|
+
assert.equal(host.lockHolder({ pid: 4242, at: now - host.LOCK_STALE_MS - 1 }, { now, alive, self: 1 }), null, 'a stale beat is a dead loop, whatever now owns that pid');
|
|
152
|
+
assert.equal(host.lockHolder({ pid: 4242, at: now - 1000 }, { now, alive: () => false, self: 1 }), null);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── stop is honest ──────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
const sleeper = () => spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', detached: process.platform !== 'win32' });
|
|
158
|
+
const waitGone = async (pid) => { for (let i = 0; i < 80 && host.pidAlive(pid); i++) await new Promise((r) => setTimeout(r, 100)); return !host.pidAlive(pid); };
|
|
159
|
+
|
|
160
|
+
function stage(name, { loopPid, supervisorPid }) {
|
|
161
|
+
const home = fresh(name);
|
|
162
|
+
const paths = host.hostPaths({ AUTOBONGOS_HOME: home }, home);
|
|
163
|
+
if (loopPid) writeFileSync(paths.lock, JSON.stringify({ pid: loopPid, at: Date.now() }));
|
|
164
|
+
if (supervisorPid) {
|
|
165
|
+
mkdirSync(path.join(paths.configRoot, 'cloudbongos'), { recursive: true });
|
|
166
|
+
writeFileSync(path.join(paths.configRoot, 'cloudbongos', 'autobongos-heartbeat.json'), JSON.stringify({ pid: supervisorPid, at: Date.now() }));
|
|
167
|
+
}
|
|
168
|
+
return paths;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
test('stop ends the loop AND the supervisor, watches both exit, and only then says stopped', async () => {
|
|
172
|
+
const loop = sleeper(); const sup = sleeper();
|
|
173
|
+
const paths = stage('stop-real', { loopPid: loop.pid, supervisorPid: sup.pid });
|
|
174
|
+
const out = await host.stopRunner({ paths, unload: () => {} });
|
|
175
|
+
assert.equal(out.outcome, 'stopped');
|
|
176
|
+
assert.deepEqual([...out.pids].sort(), [loop.pid, sup.pid].sort());
|
|
177
|
+
assert.equal(await waitGone(loop.pid), true, 'the loop is really gone');
|
|
178
|
+
assert.equal(await waitGone(sup.pid), true, 'and so is the supervisor');
|
|
179
|
+
assert.equal(existsSync(paths.lock), false, 'the lock goes with it');
|
|
180
|
+
assert.match(host.stopReport(out, { installed: true }).join('\n'), /watched, not assumed[\s\S]*starts again the next time you sign in/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a process that outlives the wait is reported NOT STOPPED, with the command that ends it', async () => {
|
|
184
|
+
const paths = stage('stop-stuck', { loopPid: 999001, supervisorPid: null });
|
|
185
|
+
const killed = [];
|
|
186
|
+
const out = await host.stopRunner({
|
|
187
|
+
paths, unload: () => {}, waitMs: 400, alive: (p) => p === 999001,
|
|
188
|
+
kill: (p, o) => killed.push([p, o.force]), sleep: (ms) => new Promise((r) => setTimeout(r, Math.min(ms, 50))),
|
|
189
|
+
});
|
|
190
|
+
assert.equal(out.outcome, 'still_running');
|
|
191
|
+
assert.deepEqual(out.left, [999001]);
|
|
192
|
+
assert.ok(killed.some(([, force]) => force === false) && killed.some(([, force]) => force === true), 'asked nicely, then forced');
|
|
193
|
+
assert.equal(existsSync(paths.lock), true, 'a live loop keeps its lock');
|
|
194
|
+
const win = host.stopReport(out, { platform: 'win32' }).join('\n');
|
|
195
|
+
assert.match(win, /NOT STOPPED[\s\S]*taskkill \/F \/T \/PID 999001/);
|
|
196
|
+
assert.match(host.stopReport(out, { platform: 'darwin' }).join('\n'), /kill -9 999001/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('stopping a runner that is not running says so, and never claims to have stopped it', async () => {
|
|
200
|
+
const paths = stage('stop-idle', { loopPid: 999002, supervisorPid: 999003 });
|
|
201
|
+
const out = await host.stopRunner({ paths, unload: () => {}, alive: () => false, kill: () => { throw new Error('nothing to kill'); } });
|
|
202
|
+
assert.equal(out.outcome, 'not_running');
|
|
203
|
+
assert.match(host.stopReport(out).join('\n'), /was not running/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('on a Mac the agent is unloaded BEFORE anything is killed, or launchd restarts the loop', async () => {
|
|
207
|
+
const paths = stage('stop-order', { loopPid: 999004, supervisorPid: null });
|
|
208
|
+
const order = [];
|
|
209
|
+
let alive = true;
|
|
210
|
+
await host.stopRunner({ paths, platform: 'darwin', unload: () => order.push('unload'), alive: () => alive, kill: () => { order.push('kill'); alive = false; } });
|
|
211
|
+
assert.deepEqual(order, ['unload', 'kill']);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── its own login, apart from the builder's ────────────────────────────────
|
|
215
|
+
|
|
216
|
+
test('the supervisor runs under the runner\'s own config root, and keeps a gauge the device already has', () => {
|
|
217
|
+
const paths = host.hostPaths({ AUTOBONGOS_HOME: path.join(tmp, 'h') }, path.join(tmp, 'h'));
|
|
218
|
+
const env = host.supervisorEnv({ PATH: 'x' }, paths, 'otb');
|
|
219
|
+
assert.equal(env.BONGOS_CONFIG_ROOT, paths.configRoot);
|
|
220
|
+
assert.match(env.AUTOBONGOS_GAUGE_FILE, /[\\/]\.config[\\/]otb[\\/]autobongos-gauge\.json$/, 'the builder-side file a statusLine writes');
|
|
221
|
+
assert.equal(host.supervisorEnv({ AUTOBONGOS_GAUGE_FILE: '/set/by/hand' }, paths).AUTOBONGOS_GAUGE_FILE, '/set/by/hand');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('under BONGOS_CONFIG_ROOT every session path moves, and the builder\'s own session is not a fallback', () => {
|
|
225
|
+
const root = fresh('cfgroot');
|
|
226
|
+
const probe = `
|
|
227
|
+
const cli = require(${JSON.stringify(path.join(REPO, 'scripts', 'bongos', 'cli-lib.js'))});
|
|
228
|
+
const ic = require(${JSON.stringify(path.join(REPO, 'src', 'instance-config.js'))});
|
|
229
|
+
const run = require(${JSON.stringify(path.join(REPO, 'scripts', 'bongos', 'autobongos-run.js'))});
|
|
230
|
+
(async () => {
|
|
231
|
+
await cli.saveSession({ token: 't'.repeat(32), api_base: 'https://hub.example', builder: { github_login: 'runner' } });
|
|
232
|
+
process.stdout.write(JSON.stringify({
|
|
233
|
+
session: cli.SESSION_PATH, reads: ic.configReadDirs(), heartbeat: run.heartbeatPath(), log: run.logPath(),
|
|
234
|
+
loaded: (await cli.loadSession()).builder.github_login,
|
|
235
|
+
}));
|
|
236
|
+
})();`;
|
|
237
|
+
const r = spawnSync(process.execPath, ['-e', probe], { encoding: 'utf8', env: { ...process.env, BONGOS_CONFIG_ROOT: root, AUTOBONGOS_HEARTBEAT_FILE: '', AUTOBONGOS_LOG_FILE: '' } });
|
|
238
|
+
assert.equal(r.status, 0, r.stderr);
|
|
239
|
+
const out = JSON.parse(r.stdout);
|
|
240
|
+
const inside = (p) => path.resolve(p).startsWith(path.resolve(root) + path.sep);
|
|
241
|
+
assert.ok(inside(out.session), `session at ${out.session}`);
|
|
242
|
+
assert.ok(out.reads.every(inside), `every read dir is under the runner's root: ${out.reads.join(', ')}`);
|
|
243
|
+
assert.ok(inside(out.heartbeat) && inside(out.log), 'the supervisor\'s heartbeat and run log too');
|
|
244
|
+
assert.equal(out.loaded, 'runner');
|
|
245
|
+
// The host-keyed store saveSession also writes sits beside it, not in ~/.config.
|
|
246
|
+
assert.ok(existsSync(path.join(root, 'cloudbongos', 'instances', 'hub.example.json')), 'the per-instance store moved with it');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a worker inherits the runner\'s config root, or it would ship as the wrong session', () => {
|
|
250
|
+
const run = require('../scripts/bongos/autobongos-run.js');
|
|
251
|
+
assert.equal(run.workerEnv({ BONGOS_CONFIG_ROOT: '/runner/config', SECRET_DB_URL: 'x' }).BONGOS_CONFIG_ROOT, '/runner/config');
|
|
252
|
+
assert.equal(run.workerEnv({ SECRET_DB_URL: 'x' }).SECRET_DB_URL, undefined, 'and the allowlist still refuses everything else');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── keep-awake ──────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
test('the device is held awake while the runner may work, and released when the fence stops it', () => {
|
|
258
|
+
assert.equal(host.wantsAwake('worked', false), true);
|
|
259
|
+
assert.equal(host.wantsAwake('nothing_claimable', false), true, 'waiting for work is still a runner someone pressed Run on');
|
|
260
|
+
assert.equal(host.wantsAwake('hold', false), true, 'resting until a usage reset must wake to resume');
|
|
261
|
+
assert.equal(host.wantsAwake('fenced', true), false, 'stopped: a laptop must be allowed to sleep');
|
|
262
|
+
assert.equal(host.wantsAwake('boot_reconcile', true), true, 'a boot row says nothing either way');
|
|
263
|
+
assert.equal(host.wantsAwake('boot_reconcile', false), false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('keep-awake starts one holder, stops it on release, and can be switched off', () => {
|
|
267
|
+
const made = [];
|
|
268
|
+
const fake = () => { const c = { killed: false, on() {}, kill() { c.killed = true; } }; made.push(c); return c; };
|
|
269
|
+
const k = host.makeKeepAwake({ platform: 'darwin', env: {}, spawnFn: fake, pid: 77 });
|
|
270
|
+
k.set(true); k.set(true);
|
|
271
|
+
assert.equal(made.length, 1, 'asking twice holds once');
|
|
272
|
+
assert.equal(k.on, true);
|
|
273
|
+
k.set(false);
|
|
274
|
+
assert.equal(made[0].killed, true);
|
|
275
|
+
assert.equal(k.on, false);
|
|
276
|
+
|
|
277
|
+
const off = host.makeKeepAwake({ platform: 'darwin', env: { AUTOBONGOS_KEEP_AWAKE: '0' }, spawnFn: fake, pid: 77 });
|
|
278
|
+
off.set(true);
|
|
279
|
+
assert.equal(made.length, 1, 'AUTOBONGOS_KEEP_AWAKE=0 holds nothing');
|
|
280
|
+
|
|
281
|
+
assert.deepEqual(host.keepAwakeCommand('darwin', 77), { cmd: 'caffeinate', args: ['-i', '-w', '77'] });
|
|
282
|
+
const win = host.keepAwakeCommand('win32', 77);
|
|
283
|
+
assert.equal(win.cmd, 'powershell.exe');
|
|
284
|
+
assert.match(win.args.join(' '), /SetThreadExecutionState\(2147483649\)[\s\S]*Wait-Process -Id 77/);
|
|
285
|
+
assert.equal(host.keepAwakeCommand('linux', 77), null);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── the start entries ───────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
test('the Windows start entry runs headless when conhost is there, and quotes every path with a space', () => {
|
|
291
|
+
const argv = host.windowsRunArgv({ node: 'C:\\Program Files\\nodejs\\node.exe', script: 'C:\\Users\\A B\\.cloudbongos-runner\\repo\\scripts\\bongos\\autobongos-host.js', conhost: 'C:\\Windows\\System32\\conhost.exe', hasConhost: true });
|
|
292
|
+
assert.deepEqual(argv.slice(0, 2), ['C:\\Windows\\System32\\conhost.exe', '--headless']);
|
|
293
|
+
assert.equal(
|
|
294
|
+
host.windowsRunCommand(argv),
|
|
295
|
+
'C:\\Windows\\System32\\conhost.exe --headless "C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\A B\\.cloudbongos-runner\\repo\\scripts\\bongos\\autobongos-host.js" run',
|
|
296
|
+
);
|
|
297
|
+
const bare = host.windowsRunArgv({ node: 'C:\\n\\node.exe', script: 'C:\\r\\autobongos-host.js', hasConhost: false });
|
|
298
|
+
assert.deepEqual(bare, ['C:\\n\\node.exe', 'C:\\r\\autobongos-host.js', 'run'], 'without conhost it still starts');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('this program starts the Windows loop through cmd start, never by spawning conhost itself', () => {
|
|
302
|
+
// Found by running it: node hands a child its standard handles, and a headless
|
|
303
|
+
// conhost given handles runs nothing and exits 0. `start` gives it none.
|
|
304
|
+
const argv = ['C:\\Windows\\System32\\conhost.exe', '--headless', 'C:\\Program Files\\nodejs\\node.exe', 'C:\\Users\\A B\\repo\\scripts\\bongos\\autobongos-host.js', 'run'];
|
|
305
|
+
const { cmd, args, options } = host.windowsStartSpawn(argv);
|
|
306
|
+
assert.equal(cmd, 'cmd.exe');
|
|
307
|
+
assert.deepEqual(args.slice(0, 3), ['/d', '/s', '/c']);
|
|
308
|
+
assert.equal(args[3], '"start "" "C:\\Windows\\System32\\conhost.exe" "--headless" "C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\A B\\repo\\scripts\\bongos\\autobongos-host.js" "run""');
|
|
309
|
+
// Real Windows paths: every backslash survives, and nothing in the line is a
|
|
310
|
+
// control character (a path typed with single backslashes turns \n and \r
|
|
311
|
+
// into a newline and a carriage return, and cmd would run a different line).
|
|
312
|
+
assert.equal((args[3].match(/\\/g) || []).length, 12);
|
|
313
|
+
assert.equal(/[\x00-\x1f]/.test(args[3]), false);
|
|
314
|
+
assert.equal(options.windowsVerbatimArguments, true, 'cmd must receive that line exactly as written');
|
|
315
|
+
assert.equal(options.detached, true);
|
|
316
|
+
assert.equal(options.stdio, 'ignore');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('a PC that already runs the boot-time scheduled task is recognised, so setup can refuse a second runner', () => {
|
|
320
|
+
const asked = [];
|
|
321
|
+
const run = (status) => (cmd, args) => { asked.push([cmd, ...args]); return { status }; };
|
|
322
|
+
assert.equal(host.legacyBootTaskInstalled('win32', run(0)), true);
|
|
323
|
+
assert.equal(host.legacyBootTaskInstalled('win32', run(1)), false);
|
|
324
|
+
assert.deepEqual(asked[0], ['schtasks', '/Query', '/TN', 'Autobongos']);
|
|
325
|
+
assert.equal(host.legacyBootTaskInstalled('darwin', () => { throw new Error('a Mac has no scheduled tasks to ask'); }), false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('the Mac start entry is a per-user agent that starts at sign-in, restarts, and carries the PATH it was installed with', () => {
|
|
329
|
+
const paths = host.hostPaths({}, '/Users/a&b');
|
|
330
|
+
const plist = host.launchdPlist({ node: '/Users/a&b/.cloudbongos-runner/node/bin/node', script: '/r/autobongos-host.js', cwd: '/r', paths, envPath: '/opt/homebrew/bin:/usr/bin', home: '/Users/a&b' });
|
|
331
|
+
assert.match(plist, new RegExp(`<key>Label</key><string>${host.LABEL}</string>`));
|
|
332
|
+
assert.match(plist, /<key>RunAtLoad<\/key><true\/>/);
|
|
333
|
+
assert.match(plist, /<key>KeepAlive<\/key><true\/>/);
|
|
334
|
+
assert.match(plist, /<string>\/Users\/a&b\/\.cloudbongos-runner\/node\/bin\/node<\/string>\s*<string>\/r\/autobongos-host\.js<\/string>\s*<string>run<\/string>/, 'node, the program, run — escaped');
|
|
335
|
+
assert.match(plist, /<key>PATH<\/key><string>\/opt\/homebrew\/bin:\/usr\/bin<\/string>/);
|
|
336
|
+
assert.match(plist, /<key>AUTOBONGOS_SUPERVISOR<\/key><string>launchd<\/string>/);
|
|
337
|
+
assert.doesNotMatch(plist, /a&b/, 'no raw ampersand: an unescaped one makes launchd refuse the whole file');
|
|
338
|
+
assert.match(paths.plist, /Library[\\/]LaunchAgents[\\/]com\.cloudbongos\.autobongos\.plist$/, 'a LaunchAgent in the person\'s own Library, never a system daemon');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── setup's two outward calls ───────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
test('the code exchange turns each refusal into a sentence with a next step', async () => {
|
|
344
|
+
const ok = await host.exchangeCode({ apiBase: 'https://hub.example', code: 'abr_x', fetchImpl: async (url, init) => {
|
|
345
|
+
assert.equal(url, 'https://hub.example/api/bongos/auth/runner-code/exchange');
|
|
346
|
+
assert.deepEqual(JSON.parse(init.body), { code: 'abr_x' });
|
|
347
|
+
return { ok: true, status: 200, json: async () => ({ token: 'tok', builder_login: 'ada' }) };
|
|
348
|
+
} });
|
|
349
|
+
assert.equal(ok.token, 'tok');
|
|
350
|
+
|
|
351
|
+
const stop = async (fetchImpl) => { try { await host.exchangeCode({ apiBase: 'https://hub.example', code: 'abr_x', fetchImpl }); } catch (e) { return e; } return null; };
|
|
352
|
+
const used = await stop(async () => ({ ok: false, status: 401, json: async () => ({}) }));
|
|
353
|
+
assert.equal(used.name, 'Stop');
|
|
354
|
+
assert.match(used.message, /already used, or older than 15 minutes/);
|
|
355
|
+
assert.match(used.fix, /Add a device again/);
|
|
356
|
+
const down = await stop(async () => { throw new Error('ECONNREFUSED 10.0.0.1:443'); });
|
|
357
|
+
assert.match(down.message, /could not reach https:\/\/hub\.example/);
|
|
358
|
+
assert.doesNotMatch(`${down.message} ${down.fix}`, /ECONNREFUSED|10\.0\.0\.1/, 'no raw error text reaches the person');
|
|
359
|
+
const odd = await stop(async () => ({ ok: true, status: 200, json: async () => ({}) }));
|
|
360
|
+
assert.match(odd.message, /without a login/);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('running the same setup command twice keeps the login and never spends the used code', async () => {
|
|
364
|
+
// The pasted line carries a code that works once. The second run must change
|
|
365
|
+
// nothing, not stop on "already used".
|
|
366
|
+
const calls = [];
|
|
367
|
+
const fetchImpl = async (url) => {
|
|
368
|
+
calls.push(url);
|
|
369
|
+
if (url.endsWith('/api/bongos/me')) return { ok: true, status: 200, json: async () => ({}) };
|
|
370
|
+
return { ok: false, status: 401, json: async () => ({}) }; // the code is spent
|
|
371
|
+
};
|
|
372
|
+
const have = { token: 'tok', api_base: 'https://hub.example', builder: { github_login: 'ada' } };
|
|
373
|
+
const again = await host.signIn({ code: 'abr_used', apiBase: 'https://hub.example/', session: have, fetchImpl });
|
|
374
|
+
assert.equal(again.fresh, false);
|
|
375
|
+
assert.equal(again.session, have, 'the login it already had');
|
|
376
|
+
assert.deepEqual(calls, ['https://hub.example/api/bongos/me'], 'the code was never sent');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('the code is traded when there is no login, the login is refused, or it belongs to another project', async () => {
|
|
380
|
+
const exchange = { ok: true, status: 200, json: async () => ({ token: 'new', builder_login: 'ada', session_id: '9', label: 'studio' }) };
|
|
381
|
+
const mk = (meOk) => { const calls = []; return { calls, fetchImpl: async (url) => { calls.push(url.replace('https://hub.example/api/bongos', '')); return url.endsWith('/me') ? { ok: meOk, status: meOk ? 200 : 401, json: async () => ({}) } : exchange; } }; };
|
|
382
|
+
|
|
383
|
+
const none = mk(true);
|
|
384
|
+
const first = await host.signIn({ code: 'abr_x', apiBase: 'https://hub.example', session: null, fetchImpl: none.fetchImpl, hostName: 'pc' });
|
|
385
|
+
assert.equal(first.fresh, true);
|
|
386
|
+
assert.deepEqual(first.session, { token: 'new', api_base: 'https://hub.example', builder: { github_login: 'ada' }, runner: { label: 'studio', session_id: '9' } });
|
|
387
|
+
assert.deepEqual(none.calls, ['/auth/runner-code/exchange'], 'no login to check, so straight to the exchange');
|
|
388
|
+
|
|
389
|
+
const removed = mk(false); // the device was removed in Settings: its login is refused
|
|
390
|
+
const second = await host.signIn({ code: 'abr_x', apiBase: 'https://hub.example', session: { token: 'old', api_base: 'https://hub.example' }, fetchImpl: removed.fetchImpl });
|
|
391
|
+
assert.equal(second.fresh, true);
|
|
392
|
+
assert.deepEqual(removed.calls, ['/me', '/auth/runner-code/exchange']);
|
|
393
|
+
|
|
394
|
+
const other = mk(true); // signed in to a different project: that login is not this one's
|
|
395
|
+
const third = await host.signIn({ code: 'abr_x', apiBase: 'https://hub.example', session: { token: 'old', api_base: 'https://elsewhere.example' }, fetchImpl: other.fetchImpl });
|
|
396
|
+
assert.equal(third.fresh, true);
|
|
397
|
+
assert.deepEqual(other.calls, ['/auth/runner-code/exchange'], 'another project\'s login is never even asked about');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('with no code, a device keeps a good login and is told plainly when it has none or it was removed', async () => {
|
|
401
|
+
const good = async () => ({ ok: true, status: 200, json: async () => ({}) });
|
|
402
|
+
const refused = async () => ({ ok: false, status: 401, json: async () => ({}) });
|
|
403
|
+
const have = { token: 'tok', api_base: 'https://hub.example' };
|
|
404
|
+
assert.equal((await host.signIn({ session: have, fetchImpl: good })).fresh, false);
|
|
405
|
+
const stop = async (args) => { try { await host.signIn(args); } catch (e) { return e; } return null; };
|
|
406
|
+
const none = await stop({ session: null, fetchImpl: good });
|
|
407
|
+
assert.equal(none.name, 'Stop');
|
|
408
|
+
assert.match(none.message, /no runner login yet/);
|
|
409
|
+
assert.match(none.fix, /Add a device/);
|
|
410
|
+
const gone = await stop({ session: have, fetchImpl: refused });
|
|
411
|
+
assert.match(gone.message, /no longer accepts this device/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('the code and the login only ever cross the network over https', async () => {
|
|
415
|
+
for (const ok of ['https://hub.example', 'https://hub.example:8443', 'http://localhost:3000', 'http://127.0.0.1:4600', 'http://app.localhost', 'http://hall.test:3000']) {
|
|
416
|
+
assert.equal(host.cleanBase(`${ok}/`), ok, ok);
|
|
417
|
+
}
|
|
418
|
+
for (const bad of ['http://hub.example', 'http://evil.localhost.example', 'ftp://hub.example', 'https://hub.example/path', 'hub.example', '', null]) {
|
|
419
|
+
assert.equal(host.cleanBase(bad), null, String(bad));
|
|
420
|
+
}
|
|
421
|
+
let sent = false;
|
|
422
|
+
const e = await host.signIn({ code: 'abr_x', apiBase: 'http://hub.example', session: null, fetchImpl: async () => { sent = true; return { ok: true, json: async () => ({ token: 't' }) }; } }).catch((err) => err);
|
|
423
|
+
assert.equal(e.name, 'Stop');
|
|
424
|
+
assert.match(e.message, /not a secure \(https\) address/);
|
|
425
|
+
assert.equal(sent, false, 'nothing was sent to a plain-http project');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('the Windows start entry is written, checked and removed through reg, and uninstall looks before it answers', () => {
|
|
429
|
+
const home = fresh('reg-home');
|
|
430
|
+
const paths = host.hostPaths({ AUTOBONGOS_HOME: home }, home);
|
|
431
|
+
const clone = fresh('reg-clone');
|
|
432
|
+
mkdirSync(path.join(clone, '.git'));
|
|
433
|
+
writeFileSync(host.runnerCopyMarkPath(clone), 'marked\n');
|
|
434
|
+
// A stand-in registry: one value, present or not.
|
|
435
|
+
let present = false;
|
|
436
|
+
const asked = [];
|
|
437
|
+
const reg = (cmd, args) => {
|
|
438
|
+
asked.push([cmd, args[0], ...args.slice(1, 4)]);
|
|
439
|
+
if (args[0] === 'add') { present = true; return { status: 0 }; }
|
|
440
|
+
if (args[0] === 'delete') { present = false; return { status: 0 }; }
|
|
441
|
+
return { status: present ? 0 : 1 }; // query
|
|
442
|
+
};
|
|
443
|
+
assert.equal(host.installedState('win32', paths, reg), false);
|
|
444
|
+
assert.deepEqual(host.install({ platform: 'win32', paths, run: reg, repoRoot: clone }), { kind: 'windows-run-entry' });
|
|
445
|
+
assert.equal(host.installedState('win32', paths, reg), true);
|
|
446
|
+
const add = asked.find((a) => a[1] === 'add');
|
|
447
|
+
assert.deepEqual(add, ['reg', 'add', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', '/v', 'Autobongos'], 'the person\'s own Run key: no administrator');
|
|
448
|
+
|
|
449
|
+
assert.equal(host.removeStartEntry({ platform: 'win32', paths, run: reg }), true, 'gone, and seen to be gone');
|
|
450
|
+
assert.equal(host.installedState('win32', paths, reg), false);
|
|
451
|
+
|
|
452
|
+
// A delete that does not take: uninstall must say so, not promise it is gone.
|
|
453
|
+
present = true;
|
|
454
|
+
const stuck = (cmd, args) => (args[0] === 'query' ? { status: 0 } : { status: 1 });
|
|
455
|
+
assert.equal(host.removeStartEntry({ platform: 'win32', paths, run: stuck }), false);
|
|
456
|
+
|
|
457
|
+
// And a refused write is a sentence, not a thrown reg error.
|
|
458
|
+
assert.throws(() => host.install({ platform: 'win32', paths, run: () => ({ status: 1 }), repoRoot: clone }), (e) => e.name === 'Stop' && /would not save the start-at-sign-in entry/.test(e.message));
|
|
459
|
+
// Installing from a working copy is refused before anything is written.
|
|
460
|
+
let touched = false;
|
|
461
|
+
assert.throws(() => host.install({ platform: 'win32', paths, run: () => { touched = true; return { status: 0 }; }, repoRoot: fresh('not-a-copy') }), (e) => e.name === 'Stop');
|
|
462
|
+
assert.equal(touched, false);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('the Mac start entry is a file in LaunchAgents: install writes it, uninstall removes it and checks', () => {
|
|
466
|
+
const home = fresh('mac-home');
|
|
467
|
+
const paths = host.hostPaths({ AUTOBONGOS_HOME: path.join(home, 'runner') }, home);
|
|
468
|
+
const clone = fresh('mac-clone');
|
|
469
|
+
mkdirSync(path.join(clone, '.git'));
|
|
470
|
+
writeFileSync(host.runnerCopyMarkPath(clone), 'marked\n');
|
|
471
|
+
const never = () => { throw new Error('a Mac install must not call reg'); };
|
|
472
|
+
assert.equal(host.installedState('darwin', paths, never), false);
|
|
473
|
+
assert.deepEqual(host.install({ platform: 'darwin', paths, run: never, repoRoot: clone }), { kind: 'launchd-agent' });
|
|
474
|
+
assert.equal(existsSync(paths.plist), true);
|
|
475
|
+
assert.match(readFileSync(paths.plist, 'utf8'), /<key>Label<\/key><string>com\.cloudbongos\.autobongos<\/string>/);
|
|
476
|
+
assert.equal(host.installedState('darwin', paths, never), true);
|
|
477
|
+
assert.equal(host.removeStartEntry({ platform: 'darwin', paths, run: never }), true);
|
|
478
|
+
assert.equal(existsSync(paths.plist), false);
|
|
479
|
+
assert.equal(host.removeStartEntry({ platform: 'darwin', paths, run: never }), true, 'removing what is already gone is still "gone"');
|
|
480
|
+
// A system with neither has no entry, and says so rather than failing.
|
|
481
|
+
assert.deepEqual(host.install({ platform: 'linux', paths, run: never, repoRoot: clone }), { kind: 'none' });
|
|
482
|
+
assert.equal(host.installedState('linux', paths, never), false);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('status reads the device\'s own files: running only on a fresh lock held by a live process, and whose login it holds', () => {
|
|
486
|
+
const home = fresh('status-home');
|
|
487
|
+
const paths = host.hostPaths({ AUTOBONGOS_HOME: home }, home);
|
|
488
|
+
const now = 5_000_000;
|
|
489
|
+
const notInstalled = () => ({ status: 1 });
|
|
490
|
+
const base = { platform: 'win32', paths, configDirName: 'cloudbongos', now, run: notInstalled };
|
|
491
|
+
|
|
492
|
+
let s = host.status({ ...base, alive: () => true });
|
|
493
|
+
assert.deepEqual([s.running, s.pid, s.signed_in_as, s.project, s.starts_at_sign_in], [false, null, null, null, false], 'a device that was never set up');
|
|
494
|
+
assert.equal(s.log, paths.log);
|
|
495
|
+
|
|
496
|
+
writeFileSync(paths.lock, JSON.stringify({ pid: 4242, at: now - 1000 }));
|
|
497
|
+
mkdirSync(path.join(paths.configRoot, 'cloudbongos'), { recursive: true });
|
|
498
|
+
writeFileSync(path.join(paths.configRoot, 'cloudbongos', 'bongos-session.json'), JSON.stringify({ token: 'SECRET-TOKEN', api_base: 'https://hub.example', builder: { github_login: 'ada' } }));
|
|
499
|
+
s = host.status({ ...base, alive: () => true, run: () => ({ status: 0 }) });
|
|
500
|
+
assert.deepEqual([s.running, s.pid, s.signed_in_as, s.project, s.starts_at_sign_in], [true, 4242, 'ada', 'https://hub.example', true]);
|
|
501
|
+
assert.equal(JSON.stringify(s).includes('SECRET-TOKEN'), false, 'status names whose login it is and never prints the login');
|
|
502
|
+
|
|
503
|
+
assert.equal(host.status({ ...base, alive: () => false }).running, false, 'a lock whose process is gone');
|
|
504
|
+
assert.equal(host.status({ ...base, now: now + host.LOCK_STALE_MS + 2000, alive: () => true }).running, false, 'a lock nobody has refreshed');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('Claude is read as installed and signed in from its own answers, with a fallback for an older CLI', () => {
|
|
508
|
+
const resolveBin = () => ({ bin: 'claude', shell: false });
|
|
509
|
+
const run = (answers) => (bin, args) => answers[args.join(' ')] || { status: 1, stdout: '' };
|
|
510
|
+
const yes = host.claudeState({ resolveBin, claudeAccountLabel: () => null, run: run({ '--version': { status: 0, stdout: '2.1.0' }, 'auth status': { status: 0, stdout: '{"loggedIn":true}' } }) });
|
|
511
|
+
assert.deepEqual([yes.installed, yes.signedIn], [true, true]);
|
|
512
|
+
const no = host.claudeState({ resolveBin, claudeAccountLabel: () => 'ada@example.com', run: run({ '--version': { status: 0, stdout: '2.1.0' }, 'auth status': { status: 0, stdout: '{"loggedIn":false}' } }) });
|
|
513
|
+
assert.equal(no.signedIn, false, 'its own "no" wins over a stale file');
|
|
514
|
+
const old = host.claudeState({ resolveBin, claudeAccountLabel: () => 'ada@example.com', run: run({ '--version': { status: 0, stdout: '1.0.0' } }) });
|
|
515
|
+
assert.equal(old.signedIn, true, 'an older CLI with no auth command falls back to the recorded sign-in');
|
|
516
|
+
const none = host.claudeState({ resolveBin, claudeAccountLabel: () => null, run: () => ({ status: 1, error: new Error('ENOENT') }) });
|
|
517
|
+
assert.deepEqual([none.installed, none.signedIn], [false, false]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('setup waits for THIS device\'s check-in, not anyone\'s', async () => {
|
|
521
|
+
const session = { api_base: 'https://hub.example', token: 'tok' };
|
|
522
|
+
const rows = (list) => async () => ({ ok: true, json: async () => ({ runners: list }) });
|
|
523
|
+
const nowIso = new Date().toISOString();
|
|
524
|
+
const sleep = async () => {};
|
|
525
|
+
assert.equal(await host.waitForCheckIn({ session, sleep, timeoutMs: 200, fetchImpl: rows([{ host: 'some-other-pc', last_seen_at: nowIso }]) }), false);
|
|
526
|
+
assert.equal(await host.waitForCheckIn({ session, sleep, timeoutMs: 200, fetchImpl: rows([{ host: hostname().toUpperCase(), last_seen_at: nowIso }]) }), true, 'the server lowercases host names');
|
|
527
|
+
assert.equal(await host.waitForCheckIn({ session, sleep, timeoutMs: 200, fetchImpl: rows([{ host: hostname(), last_seen_at: '2020-01-01T00:00:00Z' }]) }), false, 'a check-in from a past install does not count');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ── the bootstrap scripts ───────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
const SH = fileURLToPath(new URL('../scripts/bongos/autobongos-bootstrap.sh', import.meta.url));
|
|
533
|
+
const PS1 = fileURLToPath(new URL('../scripts/bongos/autobongos-bootstrap.ps1', import.meta.url));
|
|
534
|
+
const bash = spawnSync('bash', ['--version'], { encoding: 'utf8' }).status === 0 ? 'bash' : null;
|
|
535
|
+
if (!bash && process.env.CI) throw new Error('no bash on this CI runner — the bootstrap cases must run, not skip');
|
|
536
|
+
|
|
537
|
+
test('the shell bootstrap stops with a sentence on an incomplete command, and changes nothing on a dry run', { skip: bash ? false : 'no bash on this machine' }, () => {
|
|
538
|
+
const home = fresh('sh-home');
|
|
539
|
+
const part = spawnSync(bash, [SH, '--code', 'abr_x'], { encoding: 'utf8', env: { ...process.env, AUTOBONGOS_HOME: home } });
|
|
540
|
+
assert.equal(part.status, 1);
|
|
541
|
+
assert.match(part.stderr, /Autobongos setup stopped here: the setup command is incomplete\.\nWhat to do: Copy the whole command again/);
|
|
542
|
+
|
|
543
|
+
const bad = spawnSync(bash, [SH, '--code', 'abr_x', '--api-base', 'https://hub.example', '--repo', 'git@github.com:a/b.git'], { encoding: 'utf8', env: { ...process.env, AUTOBONGOS_HOME: home } });
|
|
544
|
+
assert.equal(bad.status, 1);
|
|
545
|
+
assert.match(bad.stderr, /project address in the command does not look right/);
|
|
546
|
+
|
|
547
|
+
// The code is sent to the project address, so a plain-http one is refused
|
|
548
|
+
// before anything is installed or copied; this machine's own is allowed.
|
|
549
|
+
const clear = spawnSync(bash, [SH, '--code', 'abr_x', '--api-base', 'http://hub.example', '--repo', 'https://github.com/a/b', '--dry-run'], { encoding: 'utf8', env: { ...process.env, AUTOBONGOS_HOME: home } });
|
|
550
|
+
assert.equal(clear.status, 1);
|
|
551
|
+
assert.match(clear.stderr, /not a secure \(https\) address/);
|
|
552
|
+
const local = spawnSync(bash, [SH, '--code', 'abr_x', '--api-base', 'http://localhost:3000', '--repo', 'https://github.com/a/b', '--dry-run'], { encoding: 'utf8', env: { ...process.env, AUTOBONGOS_HOME: home } });
|
|
553
|
+
assert.equal(local.status, 0, local.stderr);
|
|
554
|
+
|
|
555
|
+
const dry = spawnSync(bash, [SH, '--code', 'abr_x', '--api-base', 'https://hub.example', '--repo', 'https://github.com/a/b', '--dry-run'], { encoding: 'utf8', env: { ...process.env, AUTOBONGOS_HOME: home } });
|
|
556
|
+
assert.equal(dry.status, 0, dry.stderr);
|
|
557
|
+
assert.match(dry.stdout, /Would make the runner's copy of the project in /);
|
|
558
|
+
assert.equal(existsSync(path.join(home, 'repo')), false, 'a dry run makes no copy');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('the shell bootstrap makes the runner its own copy and hands over to the device program', { skip: bash ? false : 'no bash on this machine' }, () => {
|
|
562
|
+
// A local repository stands in for GitHub: one commit carrying a stand-in
|
|
563
|
+
// device program that records how it was called.
|
|
564
|
+
const origin = fresh('origin');
|
|
565
|
+
const g = (args, cwd) => { const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); return r; };
|
|
566
|
+
g(['init', '-q', '-b', 'main'], origin);
|
|
567
|
+
mkdirSync(path.join(origin, 'scripts', 'bongos'), { recursive: true });
|
|
568
|
+
writeFileSync(path.join(origin, 'scripts', 'bongos', 'autobongos-host.js'),
|
|
569
|
+
'require("fs").writeFileSync(require("path").join(process.env.AUTOBONGOS_HOME, "handover.json"), JSON.stringify(process.argv.slice(2)));\n');
|
|
570
|
+
g(['add', '-A'], origin);
|
|
571
|
+
g(['-c', 'user.name=t', '-c', 'user.email=t@example.com', 'commit', '-q', '-m', 'init'], origin);
|
|
572
|
+
|
|
573
|
+
const home = fresh('sh-real');
|
|
574
|
+
// A stand-in `claude` on the PATH. A machine without Claude Code (CI) would
|
|
575
|
+
// otherwise have this test INSTALL it from the network, which is the script
|
|
576
|
+
// doing its job and the last thing a unit test should set off.
|
|
577
|
+
const bin = fresh('sh-bin');
|
|
578
|
+
writeFileSync(path.join(bin, 'claude'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
579
|
+
// The script only accepts an https address; point that address at the local repo.
|
|
580
|
+
const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`, AUTOBONGOS_HOME: home, GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `url.${origin}.insteadOf`, GIT_CONFIG_VALUE_0: 'https://github.example/a/b' };
|
|
581
|
+
const args = [SH, '--code', 'abr_code', '--api-base', 'https://hub.example', '--repo', 'https://github.example/a/b'];
|
|
582
|
+
const first = spawnSync(bash, args, { encoding: 'utf8', env });
|
|
583
|
+
assert.equal(first.status, 0, first.stderr + first.stdout);
|
|
584
|
+
assert.ok(existsSync(path.join(home, 'repo', '.git')), 'the copy is in the runner\'s own folder');
|
|
585
|
+
assert.deepEqual(JSON.parse(readFileSync(path.join(home, 'handover.json'), 'utf8')),
|
|
586
|
+
['setup', '--code', 'abr_code', '--api-base', 'https://hub.example', '--mark-runner-copy']);
|
|
587
|
+
|
|
588
|
+
// Run again: the copy is brought current, not cloned twice, and it still hands over.
|
|
589
|
+
rmSync(path.join(home, 'handover.json'));
|
|
590
|
+
const second = spawnSync(bash, args, { encoding: 'utf8', env });
|
|
591
|
+
assert.equal(second.status, 0, second.stderr + second.stdout);
|
|
592
|
+
assert.match(second.stdout, /already here; bringing it up to date/);
|
|
593
|
+
assert.ok(existsSync(path.join(home, 'handover.json')));
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
function findPowerShell() {
|
|
597
|
+
for (const bin of ['pwsh', 'powershell']) {
|
|
598
|
+
if (spawnSync(bin, ['-NoProfile', '-NonInteractive', '-Command', '1'], { encoding: 'utf8' }).status === 0) return bin;
|
|
599
|
+
}
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
const PS = findPowerShell();
|
|
603
|
+
if (!PS && process.env.CI) throw new Error('no PowerShell (pwsh/powershell) on this CI runner — the bootstrap cases must run, not skip');
|
|
604
|
+
|
|
605
|
+
test('the PowerShell bootstrap stops with a sentence on an incomplete command, and is plain ASCII', { skip: PS ? false : 'no PowerShell on this machine' }, () => {
|
|
606
|
+
const part = spawnSync(PS, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', PS1, '-Code', 'abr_x'], { encoding: 'utf8' });
|
|
607
|
+
assert.equal(part.status, 1);
|
|
608
|
+
assert.match(part.stdout, /Autobongos setup stopped here: the setup command is incomplete\./);
|
|
609
|
+
assert.match(part.stdout, /What to do: Copy the whole command again/);
|
|
610
|
+
const bad = spawnSync(PS, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', PS1, '-Code', 'abr_x', '-ApiBase', 'https://hub.example', '-Repo', 'git@github.com:a/b.git'], { encoding: 'utf8' });
|
|
611
|
+
assert.equal(bad.status, 1);
|
|
612
|
+
assert.match(bad.stdout, /project address in the command does not look right/);
|
|
613
|
+
const clear = spawnSync(PS, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', PS1, '-Code', 'abr_x', '-ApiBase', 'http://hub.example', '-Repo', 'https://github.com/a/b', '-DryRun'], { encoding: 'utf8' });
|
|
614
|
+
assert.equal(clear.status, 1);
|
|
615
|
+
assert.match(clear.stdout, /not a secure \(https\) address/);
|
|
616
|
+
// Windows PowerShell 5.1 reads a BOM-less file as ANSI, so one curly quote or
|
|
617
|
+
// long dash turns into mojibake inside a string — or a parse error.
|
|
618
|
+
assert.equal(/[^\x00-\x7F]/.test(readFileSync(PS1, 'utf8')), false);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// ── the night, read from the runner's own files (task 1005097) ───────────────
|
|
622
|
+
|
|
623
|
+
test('the digest is read from the RUNNER’s run log: bare, the digest CLI looks in the builder’s own folder and finds none', () => {
|
|
624
|
+
const home = fresh('digest-home');
|
|
625
|
+
const paths = host.hostPaths({ AUTOBONGOS_HOME: home }, home);
|
|
626
|
+
const dirName = require('../src/instance-config').configDirName();
|
|
627
|
+
const logDir = path.join(paths.configRoot, dirName);
|
|
628
|
+
mkdirSync(logDir, { recursive: true });
|
|
629
|
+
writeFileSync(path.join(logDir, 'autobongos-runs.jsonl'), `${JSON.stringify({
|
|
630
|
+
at: new Date().toISOString(), event: 'worked', task_id: 424242, title: 'the task the runner built',
|
|
631
|
+
outcome: 'claims_shipped', verified: true, ledger_status: 'shipped', ledger_shape: 'shipped', cost_usd: 1.5,
|
|
632
|
+
})}\n`);
|
|
633
|
+
|
|
634
|
+
const argv = host.digestArgv(['--all', '--json']);
|
|
635
|
+
assert.equal(path.basename(argv[0]), 'autobongos-digest.js');
|
|
636
|
+
assert.deepEqual(argv.slice(1), ['--all', '--json'], 'whatever the person passed is handed through');
|
|
637
|
+
|
|
638
|
+
// Through the device program: the runner's night.
|
|
639
|
+
const viaHost = spawnSync(process.execPath, [HOST_JS, 'digest', '--all', '--json'], { encoding: 'utf8', env: { ...process.env, AUTOBONGOS_HOME: home, AUTOBONGOS_LOG_FILE: '' } });
|
|
640
|
+
assert.equal(viaHost.status, 0, viaHost.stderr);
|
|
641
|
+
assert.ok(viaHost.stdout.includes('424242'), 'the task the runner built is in it');
|
|
642
|
+
|
|
643
|
+
// Bare, with an empty folder standing in for the builder's own: nothing to read.
|
|
644
|
+
const bare = spawnSync(process.execPath, argv, { encoding: 'utf8', env: { ...process.env, BONGOS_CONFIG_ROOT: fresh('digest-builder-root'), AUTOBONGOS_LOG_FILE: '' } });
|
|
645
|
+
assert.equal(bare.status, 0, 'the digest never fails a morning');
|
|
646
|
+
assert.equal(bare.stdout.includes('424242'), false, 'which is why the guide sends people through the device program');
|
|
647
|
+
});
|