@agent-flow-board/bridge 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +26 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +274 -0
  4. package/dist/checks/askBoard.js +143 -0
  5. package/dist/checks/audit.js +3 -2
  6. package/dist/checks/bind.js +3 -2
  7. package/dist/checks/board.js +30 -0
  8. package/dist/checks/broker.js +439 -0
  9. package/dist/checks/browser.js +137 -32
  10. package/dist/checks/browserpool.js +221 -0
  11. package/dist/checks/cache.js +710 -0
  12. package/dist/checks/command.js +126 -87
  13. package/dist/checks/commands.js +315 -0
  14. package/dist/checks/connections.js +4 -3
  15. package/dist/checks/copy.js +320 -0
  16. package/dist/checks/dbpool.js +710 -0
  17. package/dist/checks/doctor.js +655 -0
  18. package/dist/checks/envPolicy.js +50 -1
  19. package/dist/checks/envrules.js +322 -0
  20. package/dist/checks/evidence.js +114 -0
  21. package/dist/checks/fsretry.js +29 -0
  22. package/dist/checks/importlegacy.js +300 -0
  23. package/dist/checks/install.js +150 -0
  24. package/dist/checks/live.js +76 -0
  25. package/dist/checks/lock.js +28 -4
  26. package/dist/checks/login.js +307 -0
  27. package/dist/checks/mailguard-hook.cjs +216 -0
  28. package/dist/checks/mailguard.js +98 -0
  29. package/dist/checks/paths.js +24 -0
  30. package/dist/checks/preview.js +418 -191
  31. package/dist/checks/profiles.js +110 -0
  32. package/dist/checks/provision.js +1 -1
  33. package/dist/checks/recipe.js +19 -5
  34. package/dist/checks/registry.js +548 -0
  35. package/dist/checks/repoenv.js +162 -0
  36. package/dist/checks/roots.js +144 -0
  37. package/dist/checks/routing.js +68 -0
  38. package/dist/checks/run.js +243 -53
  39. package/dist/checks/sandbox.js +32 -10
  40. package/dist/checks/scheduler.js +383 -0
  41. package/dist/checks/scope.js +119 -30
  42. package/dist/checks/sealedNames.js +39 -0
  43. package/dist/checks/secrets.js +454 -0
  44. package/dist/checks/secretscmd.js +80 -0
  45. package/dist/checks/service.js +589 -0
  46. package/dist/checks/signin.js +7 -1
  47. package/dist/checks/signinKey.js +104 -0
  48. package/dist/checks/singleflight.js +307 -0
  49. package/dist/checks/store.js +92 -0
  50. package/dist/checks/surface.js +124 -0
  51. package/dist/checks/warm.js +342 -0
  52. package/dist/checks/watch.js +190 -63
  53. package/dist/checks/wire.js +87 -0
  54. package/package.json +1 -1
@@ -0,0 +1,589 @@
1
+ // `check service install|uninstall|status|run`: THE CHECK WORKER AS A BACKGROUND SERVICE ON WINDOWS (A LOGON LAUNCHER AND A SUPERVISOR)
2
+ // `install` puts a small launcher in the Startup folder that runs `check service run` hidden at logon. `run` is a supervisor: it holds service.lock
3
+ // (one supervisor per machine), keeps one `check watch` child going, starts it again when it dies (waiting longer after each quick death), stops
4
+ // with one plain line when a restart can never help, and restarts the worker on a newer installed bridge, but only while the worker has no busy run.
5
+ // A process is stopped by `uninstall` only when its id AND its start time match what the lock recorded (a reused process id is never touched).
6
+ // Everything the service writes to its log passes redact.ts first.
7
+ // -- DEP-MODULES -- //
8
+ import { execFile, spawn } from 'node:child_process';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { appendFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ // -- MODULES -- //
14
+ import { machineTotals } from './broker.js';
15
+ import { pidAlive } from './lock.js';
16
+ import { checksHome, ensureDir } from './paths.js';
17
+ import { redact } from './redact.js';
18
+ // -- CONSTANTS -- //
19
+ export const LOG_LIMIT = 1_000_000;
20
+ /** A worker that lived this long was healthy: the next wait starts small again. */
21
+ export const HEALTHY_MS = 60_000;
22
+ /** Start times are compared within this many milliseconds (the lock is written a moment after the process starts). */
23
+ export const START_TOLERANCE_MS = 120_000;
24
+ /**
25
+ * What a restart can never fix. The supervisor stops and says it, instead of looping. Only the sentinel line the worker prints (watch.ts terminalLine) counts, never
26
+ * free text: a log line of an app that happens to say "not set up" must not stop the service.
27
+ */
28
+ export const TERMINAL = [
29
+ /^AFB_TERMINAL: (not-connected|rejected|needs-newer|not-set-up)\s*$/m
30
+ ];
31
+ export const LAUNCHER_NAME = 'afb-checks-bridge.vbs';
32
+ // -- FUNCTIONS -- //
33
+ const lockFile = () => join(checksHome(), 'service.lock');
34
+ const statusFile = () => join(checksHome(), 'service-status.json');
35
+ export const serviceLogFile = () => join(checksHome(), 'service.log');
36
+ const workerFile = () => join(checksHome(), 'worker.json');
37
+ const here = dirname(fileURLToPath(import.meta.url));
38
+ /** The bridge's cli.js (dist/cli.js; src/cli.ts when run from source). */
39
+ export const cliPath = () => join(here, '..', existsSync(join(here, '..', 'cli.js')) ? 'cli.js' : 'cli.ts');
40
+ /** The version in this bridge's package.json (two folders up from dist/checks or src/checks). */
41
+ export function installedBridgeVersion() {
42
+ try {
43
+ const v = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
44
+ const ver = v.version;
45
+ return typeof ver === 'string' ? ver : '0.0.0';
46
+ }
47
+ catch {
48
+ return '0.0.0';
49
+ }
50
+ }
51
+ const readJson = (file) => {
52
+ try {
53
+ return JSON.parse(readFileSync(file, 'utf8'));
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ };
59
+ // -- PURE HELPERS -- //
60
+ /** 1 s x 2^min(quickDeaths, 6), at most 60 s. */
61
+ export const backoffDelay = (quickDeaths) => Math.min(60_000, 1000 * 2 ** Math.min(Math.max(0, quickDeaths), 6));
62
+ /** The count of quick deaths after a worker that lived `livedMs`: reset after a long run. */
63
+ export const nextQuick = (quick, livedMs) => livedMs > HEALTHY_MS ? 0 : quick + 1;
64
+ export const isTerminal = (output) => TERMINAL.some((re) => re.test(output));
65
+ /** True when version `a` is higher than `b` (dotted numbers; anything after a dash or plus is ignored). */
66
+ export function versionNewer(a, b) {
67
+ const part = (v) => v
68
+ .split(/[-+]/)[0]
69
+ .split('.')
70
+ .map((n) => Number.parseInt(n, 10) || 0);
71
+ const x = part(a);
72
+ const y = part(b);
73
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
74
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
75
+ if (d !== 0)
76
+ return d > 0;
77
+ }
78
+ return false;
79
+ }
80
+ const vbsQuote = (s) => {
81
+ if (s.includes('"'))
82
+ throw new Error('A path with a double quote cannot go in the launcher.');
83
+ return `""${s}""`;
84
+ };
85
+ /** The launcher text: a Visual Basic script that runs the supervisor with no window (style 0) and does not wait. */
86
+ export function launcherText(node, cli) {
87
+ return `' afb-checks bridge: starts the check worker with no window when you sign in to Windows. Remove it with: agent-flow-board check service uninstall\r\nCreateObject("WScript.Shell").Run "${vbsQuote(node)} ${vbsQuote(cli)} check service run", 0, False\r\n`;
88
+ }
89
+ const BOM = String.fromCharCode(0xfeff);
90
+ /** The launcher bytes: UTF-16LE with a byte order mark, which Windows Script Host reads whatever the code page. */
91
+ export const launcherBytes = (node, cli) => Buffer.from(BOM + launcherText(node, cli), 'utf16le');
92
+ /** True when a process with this id is the one that wrote the lock: alive, and a start time within the tolerance. `strict` refuses an unknown start time. */
93
+ export async function sameProcess(lock, deps, strict) {
94
+ if (!lock || !Number.isInteger(lock.pid) || !deps.isAlive(lock.pid))
95
+ return false;
96
+ const started = await deps.startOf(lock.pid);
97
+ const wrote = Date.parse(lock.startedAt);
98
+ if (started === null || Number.isNaN(wrote))
99
+ return !strict;
100
+ return Math.abs(started - wrote) < START_TOLERANCE_MS;
101
+ }
102
+ function parseLock(value) {
103
+ if (!value || typeof value !== 'object')
104
+ return null;
105
+ const v = value;
106
+ return typeof v.pid === 'number' &&
107
+ typeof v.startedAt === 'string' &&
108
+ typeof v.version === 'string'
109
+ ? { pid: v.pid, startedAt: v.startedAt, version: v.version }
110
+ : null;
111
+ }
112
+ export const readServiceLock = () => parseLock(readJson(lockFile()));
113
+ /** Appends one redacted line to the service log, moving it to service.log.old once it passes 1 MB. */
114
+ export function writeLog(line, now = new Date()) {
115
+ ensureDir(checksHome());
116
+ const file = serviceLogFile();
117
+ try {
118
+ if (existsSync(file) && statSync(file).size > LOG_LIMIT) {
119
+ rmSync(`${file}.old`, { force: true });
120
+ renameSync(file, `${file}.old`);
121
+ }
122
+ }
123
+ catch {
124
+ /* A log that cannot be rotated is still appended to */
125
+ }
126
+ appendFileSync(file, `${now.toISOString()} ${redact(line)}\n`, { mode: 0o600 });
127
+ }
128
+ /** The last lines of the log, redacted again on the way out. */
129
+ export function tailLog(n = 3) {
130
+ try {
131
+ return readFileSync(serviceLogFile(), 'utf8')
132
+ .split(/\r?\n/)
133
+ .filter(Boolean)
134
+ .slice(-n)
135
+ .map((l) => redact(l));
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ }
141
+ export function readServiceStatus() {
142
+ const v = readJson(statusFile());
143
+ if (!v || typeof v !== 'object')
144
+ return null;
145
+ const s = v;
146
+ if (s.state !== 'running' && s.state !== 'restarting' && s.state !== 'stopped')
147
+ return null;
148
+ return {
149
+ state: s.state,
150
+ message: typeof s.message === 'string' ? s.message : null,
151
+ lastExit: s.lastExit,
152
+ at: typeof s.at === 'string' ? s.at : ''
153
+ };
154
+ }
155
+ export function writeServiceStatus(patch) {
156
+ ensureDir(checksHome());
157
+ const merged = {
158
+ state: 'stopped',
159
+ message: null,
160
+ ...(readServiceStatus() ?? {}),
161
+ ...patch,
162
+ at: new Date().toISOString()
163
+ };
164
+ const tmp = `${statusFile()}.${process.pid}.tmp`;
165
+ writeFileSync(tmp, JSON.stringify(merged, null, '\t') + '\n', { mode: 0o600 });
166
+ renameSync(tmp, statusFile());
167
+ }
168
+ /**
169
+ * True when no run is busy. The live slot count of the machine decides (a slot is held for as long as a run is claimed, whichever process holds it); the worker's
170
+ * status file only adds to it: a file that says some run is busy keeps the worker, a damaged one does too, a missing one means no worker has run yet.
171
+ */
172
+ export function workerIdle() {
173
+ try {
174
+ if (machineTotals().busy > 0)
175
+ return false;
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ if (!existsSync(workerFile()))
181
+ return true;
182
+ const v = readJson(workerFile());
183
+ if (!v || typeof v !== 'object')
184
+ return false;
185
+ const busy = v.busy;
186
+ return typeof busy === 'number' && busy === 0;
187
+ }
188
+ // -- PROCESS HELPERS -- //
189
+ function startOfDefault(platform) {
190
+ return (pid) => new Promise((resolve) => {
191
+ const done = (text) => {
192
+ const t = Date.parse(text.trim());
193
+ resolve(Number.isNaN(t) ? null : t);
194
+ };
195
+ try {
196
+ if (platform === 'win32')
197
+ execFile('powershell', [
198
+ '-NoProfile',
199
+ '-NonInteractive',
200
+ '-Command',
201
+ `(Get-Process -Id ${Math.trunc(pid)} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`
202
+ ], { windowsHide: true, timeout: 15_000 }, (err, stdout) => (err ? resolve(null) : done(String(stdout))));
203
+ else
204
+ execFile('ps', ['-o', 'lstart=', '-p', String(Math.trunc(pid))], { timeout: 15_000, env: { ...process.env, LC_ALL: 'C' } }, (err, stdout) => (err ? resolve(null) : done(String(stdout))));
205
+ }
206
+ catch {
207
+ resolve(null);
208
+ }
209
+ });
210
+ }
211
+ function killTreeDefault(platform) {
212
+ return (pid) => new Promise((resolve) => {
213
+ if (!pid)
214
+ return resolve();
215
+ if (platform === 'win32') {
216
+ try {
217
+ const c = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {
218
+ stdio: 'ignore',
219
+ windowsHide: true
220
+ });
221
+ c.on('error', () => resolve());
222
+ c.on('close', () => resolve());
223
+ }
224
+ catch {
225
+ resolve();
226
+ }
227
+ return;
228
+ }
229
+ try {
230
+ process.kill(pid, 'SIGTERM');
231
+ }
232
+ catch {
233
+ /* Already gone */
234
+ }
235
+ resolve();
236
+ });
237
+ }
238
+ export function defaultServiceDeps() {
239
+ const platform = process.platform;
240
+ return {
241
+ platform,
242
+ startOf: startOfDefault(platform),
243
+ isAlive: pidAlive,
244
+ killTree: killTreeDefault(platform),
245
+ startDetached() {
246
+ const c = spawn(process.execPath, [cliPath(), 'check', 'service', 'run'], {
247
+ detached: true,
248
+ stdio: 'ignore',
249
+ windowsHide: true
250
+ });
251
+ c.unref();
252
+ },
253
+ startupDir: () => process.env.AFB_CHECKS_STARTUP ??
254
+ join(process.env.APPDATA ?? '', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
255
+ };
256
+ }
257
+ const withDeps = (d) => ({ ...defaultServiceDeps(), ...d });
258
+ // -- THE LOCK -- //
259
+ /** Writes the lock whole into a temporary file and links it into place, so a second supervisor never sees half of it. */
260
+ function tryCreateLock(text) {
261
+ const file = lockFile();
262
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
263
+ writeFileSync(tmp, text, { mode: 0o600 });
264
+ try {
265
+ linkSync(tmp, file);
266
+ return true;
267
+ }
268
+ catch (e) {
269
+ if (e.code === 'EEXIST')
270
+ return false;
271
+ throw e;
272
+ }
273
+ finally {
274
+ rmSync(tmp, { force: true });
275
+ }
276
+ }
277
+ /** Takes service.lock, or returns null when a live supervisor holds it. A lock of a gone process is moved aside in one step and taken over. */
278
+ export async function acquireServiceLock(version, deps) {
279
+ ensureDir(checksHome());
280
+ const body = (v) => ({
281
+ pid: process.pid,
282
+ startedAt: new Date().toISOString(),
283
+ version: v
284
+ });
285
+ const mine = body(version);
286
+ const text = () => JSON.stringify(mine) + '\n';
287
+ let got = false;
288
+ for (let i = 0; i < 4 && !got; i++) {
289
+ if (tryCreateLock(text())) {
290
+ got = true;
291
+ break;
292
+ }
293
+ const held = readServiceLock();
294
+ if (held && (await sameProcess(held, deps, false)))
295
+ return null;
296
+ const aside = `${lockFile()}.stale.${process.pid}.${randomBytes(4).toString('hex')}`;
297
+ try {
298
+ renameSync(lockFile(), aside);
299
+ }
300
+ catch {
301
+ continue;
302
+ }
303
+ // What was moved may be a fresh lock that another supervisor made in the meantime: it goes back
304
+ const moved = parseLock(readJson(aside));
305
+ if (moved && moved.pid !== held?.pid && (await sameProcess(moved, deps, false))) {
306
+ try {
307
+ linkSync(aside, lockFile());
308
+ }
309
+ catch {
310
+ /* Another lock is already there */
311
+ }
312
+ rmSync(aside, { force: true });
313
+ return null;
314
+ }
315
+ rmSync(aside, { force: true });
316
+ }
317
+ if (!got)
318
+ return null;
319
+ return {
320
+ release() {
321
+ if (readServiceLock()?.pid === process.pid)
322
+ rmSync(lockFile(), { force: true });
323
+ },
324
+ setVersion(v) {
325
+ mine.version = v;
326
+ if (readServiceLock()?.pid === process.pid) {
327
+ const tmp = `${lockFile()}.${process.pid}.v.tmp`;
328
+ writeFileSync(tmp, text(), { mode: 0o600 });
329
+ renameSync(tmp, lockFile());
330
+ }
331
+ }
332
+ };
333
+ }
334
+ // -- THE SUPERVISOR -- //
335
+ const defaultNap = (ms, signal) => new Promise((resolve) => {
336
+ if (signal.aborted)
337
+ return resolve();
338
+ const t = setTimeout(done, ms);
339
+ function done() {
340
+ clearTimeout(t);
341
+ signal.removeEventListener('abort', done);
342
+ resolve();
343
+ }
344
+ signal.addEventListener('abort', done, { once: true });
345
+ });
346
+ /** Keeps the worker going until the signal aborts or a restart can never help. Holds service.lock all the time. */
347
+ export async function runService(opts = {}) {
348
+ const say = opts.say ?? (() => undefined);
349
+ const deps = withDeps(opts.deps);
350
+ const version = opts.version ?? installedBridgeVersion();
351
+ const installed = opts.installedVersion ?? installedBridgeVersion;
352
+ const nap = opts.nap ?? defaultNap;
353
+ const now = opts.now ?? Date.now;
354
+ const signal = opts.signal ?? new AbortController().signal;
355
+ const worker = opts.workerCommand ?? {
356
+ cmd: process.execPath,
357
+ args: [cliPath(), 'check', 'watch']
358
+ };
359
+ const checkMs = opts.versionCheckMs ?? 60_000;
360
+ const lock = await acquireServiceLock(version, deps);
361
+ if (!lock) {
362
+ say('The check service is already running on this machine.');
363
+ return { reason: 'already' };
364
+ }
365
+ let current = version;
366
+ let quick = 0;
367
+ let restarts = 0;
368
+ const held = { child: null };
369
+ const onAbort = () => {
370
+ if (held.child?.pid)
371
+ void deps.killTree(held.child.pid);
372
+ };
373
+ signal.addEventListener('abort', onAbort, { once: true });
374
+ writeLog(`service started (pid ${process.pid}, bridge ${version})`);
375
+ try {
376
+ for (;;) {
377
+ if (signal.aborted)
378
+ break;
379
+ const began = now();
380
+ writeServiceStatus({ state: 'running', message: null });
381
+ const ended = await new Promise((resolve) => {
382
+ let tail = '';
383
+ let replaced = false;
384
+ const partial = { out: '', err: '' };
385
+ const feed = (which) => (d) => {
386
+ const text = d.toString();
387
+ tail = (tail + text).slice(-4000);
388
+ const lines = (partial[which] + text).split(/\r?\n/);
389
+ partial[which] = lines.pop() ?? '';
390
+ for (const l of lines)
391
+ if (l.trim())
392
+ writeLog(`worker: ${l}`);
393
+ };
394
+ const flush = () => {
395
+ for (const rest of [partial.out, partial.err])
396
+ if (rest.trim())
397
+ writeLog(`worker: ${rest}`);
398
+ };
399
+ let timer = null;
400
+ let c;
401
+ try {
402
+ c = spawn(worker.cmd, worker.args, {
403
+ windowsHide: true,
404
+ stdio: ['ignore', 'pipe', 'pipe'],
405
+ env: { ...process.env, AFB_CHECKS_SERVICE_CHILD: '1' }
406
+ });
407
+ }
408
+ catch (e) {
409
+ return resolve({
410
+ code: -1,
411
+ out: e instanceof Error ? e.message : String(e),
412
+ replaced: false
413
+ });
414
+ }
415
+ held.child = c;
416
+ c.stdout?.on('data', feed('out'));
417
+ c.stderr?.on('data', feed('err'));
418
+ const finish = (code, extra = '') => {
419
+ if (timer)
420
+ clearInterval(timer);
421
+ flush();
422
+ held.child = null;
423
+ resolve({ code, out: tail + extra, replaced });
424
+ };
425
+ c.on('error', (e) => finish(-1, `\n${e.message}`));
426
+ c.on('close', (code) => finish(code ?? -1));
427
+ // A newer installed bridge restarts the worker, but never while a run is busy
428
+ timer = setInterval(() => {
429
+ if (replaced || signal.aborted)
430
+ return;
431
+ let latest;
432
+ try {
433
+ latest = installed();
434
+ }
435
+ catch {
436
+ return;
437
+ }
438
+ if (versionNewer(latest, current) && workerIdle()) {
439
+ replaced = true;
440
+ writeLog(`bridge ${latest} is installed (running ${current}) and no run is busy: restarting the worker`);
441
+ current = latest;
442
+ lock.setVersion(latest);
443
+ if (c.pid)
444
+ void deps.killTree(c.pid);
445
+ }
446
+ }, checkMs);
447
+ });
448
+ const lived = now() - began;
449
+ const seconds = Math.round(lived / 1000);
450
+ writeServiceStatus({
451
+ state: 'restarting',
452
+ lastExit: { code: ended.code, afterSeconds: seconds, at: new Date().toISOString() }
453
+ });
454
+ writeLog(`worker ended with code ${ended.code} after ${seconds}s`);
455
+ if (signal.aborted)
456
+ break;
457
+ if (ended.replaced) {
458
+ quick = 0;
459
+ continue;
460
+ }
461
+ if (isTerminal(ended.out)) {
462
+ const last = redact(ended.out.trim().split(/\r?\n/).slice(-3).join(' | '));
463
+ const message = last || `The worker stopped with code ${ended.code}.`;
464
+ writeServiceStatus({ state: 'stopped', message });
465
+ writeLog(`service stopped: ${message}`);
466
+ say(`The check service stopped: ${message}`);
467
+ return { reason: 'terminal', message };
468
+ }
469
+ quick = nextQuick(quick, lived);
470
+ if (++restarts > (opts.maxRestarts ?? Infinity))
471
+ return { reason: 'max' };
472
+ await nap(backoffDelay(quick), signal);
473
+ }
474
+ writeServiceStatus({ state: 'stopped', message: 'Stopped.' });
475
+ return { reason: 'stopped' };
476
+ }
477
+ finally {
478
+ signal.removeEventListener('abort', onAbort);
479
+ if (held.child)
480
+ onAbort();
481
+ lock.release();
482
+ writeLog('service ended');
483
+ }
484
+ }
485
+ // -- THE COMMAND -- //
486
+ export const SERVICE_USAGE = 'Usage: agent-flow-board check service install [--no-start] | uninstall | status [--json]';
487
+ export async function serviceCommand(args, ctx, override) {
488
+ const sub = args[0];
489
+ const deps = withDeps(override);
490
+ const launcherFile = () => join(deps.startupDir(), LAUNCHER_NAME);
491
+ try {
492
+ if (sub === 'install') {
493
+ if (deps.platform !== 'win32' && !process.env.AFB_CHECKS_STARTUP) {
494
+ ctx.log('The service launcher is for Windows (the Startup folder). On macOS or Linux run "agent-flow-board check watch" under your own service manager (launchd, systemd).');
495
+ return 2;
496
+ }
497
+ const file = launcherFile();
498
+ mkdirSync(dirname(file), { recursive: true });
499
+ writeFileSync(file, launcherBytes(process.execPath, cliPath()));
500
+ ctx.log(`Installed ${file}. The worker now starts, with no window, each time you sign in to Windows.`);
501
+ let started = false;
502
+ if (!args.includes('--no-start')) {
503
+ if (await sameProcess(readServiceLock(), deps, false))
504
+ ctx.log('The service is already running.');
505
+ else {
506
+ deps.startDetached();
507
+ started = true;
508
+ ctx.log('Started it now as well.');
509
+ }
510
+ }
511
+ if (ctx.json)
512
+ ctx.out(JSON.stringify({ ok: true, launcher: file, started }));
513
+ return 0;
514
+ }
515
+ if (sub === 'uninstall') {
516
+ const file = launcherFile();
517
+ const had = existsSync(file);
518
+ rmSync(file, { force: true });
519
+ ctx.log(had ? `Removed ${file}.` : 'The launcher was not installed.');
520
+ const lock = readServiceLock();
521
+ let stopped = false;
522
+ if (lock && deps.isAlive(lock.pid)) {
523
+ if (await sameProcess(lock, deps, true)) {
524
+ await deps.killTree(lock.pid);
525
+ stopped = true;
526
+ ctx.log('Stopped the running service.');
527
+ }
528
+ else
529
+ ctx.log(`Process ${lock.pid} is not the recorded service (its start time does not match), so it was left alone.`);
530
+ }
531
+ if (!lock || !deps.isAlive(lock.pid) || stopped)
532
+ rmSync(lockFile(), { force: true });
533
+ writeServiceStatus({ state: 'stopped', message: 'Uninstalled.' });
534
+ if (ctx.json)
535
+ ctx.out(JSON.stringify({ ok: true, removed: had, stopped }));
536
+ return 0;
537
+ }
538
+ if (sub === 'status') {
539
+ const installed = existsSync(launcherFile());
540
+ const lock = readServiceLock();
541
+ const running = await sameProcess(lock, deps, false);
542
+ const status = readServiceStatus();
543
+ const tail = tailLog(3);
544
+ if (ctx.json)
545
+ ctx.out(JSON.stringify({
546
+ installed,
547
+ running,
548
+ pid: running ? lock?.pid : null,
549
+ version: lock?.version ?? null,
550
+ status,
551
+ log: tail
552
+ }));
553
+ else {
554
+ ctx.log(`Service: ${installed ? 'installed (starts when you sign in)' : 'not installed (agent-flow-board check service install)'}, ${running ? 'running' : 'stopped'}`);
555
+ if (status?.message)
556
+ ctx.log(` ${status.state}: ${redact(status.message)}`);
557
+ if (status?.lastExit)
558
+ ctx.log(` last worker exit: code ${status.lastExit.code} after ${status.lastExit.afterSeconds}s at ${status.lastExit.at}`);
559
+ for (const l of tail)
560
+ ctx.log(` log: ${l}`);
561
+ }
562
+ return 0;
563
+ }
564
+ if (sub === 'run') {
565
+ const ac = new AbortController();
566
+ const stop = () => ac.abort();
567
+ process.once('SIGINT', stop);
568
+ process.once('SIGTERM', stop);
569
+ try {
570
+ const r = await runService({ say: ctx.log, signal: ac.signal, deps: override });
571
+ return r.reason === 'already' || r.reason === 'terminal' ? 1 : 0;
572
+ }
573
+ finally {
574
+ process.off('SIGINT', stop);
575
+ process.off('SIGTERM', stop);
576
+ }
577
+ }
578
+ ctx.log(SERVICE_USAGE);
579
+ return 2;
580
+ }
581
+ catch (e) {
582
+ const message = e instanceof Error ? e.message : String(e);
583
+ if (ctx.json)
584
+ ctx.out(JSON.stringify({ ok: false, event: 'error', error: message }));
585
+ else
586
+ ctx.log(message);
587
+ return 2;
588
+ }
589
+ }
@@ -13,6 +13,11 @@ const DEFAULT_PAGE = {
13
13
  submit: 'Sign in'
14
14
  };
15
15
  // -- FUNCTIONS -- //
16
+ /** The browser flag that makes every host except this machine and the site's own unresolvable. */
17
+ export const hostResolverRules = (siteHost) => {
18
+ const hosts = [...new Set(['127.0.0.1', 'localhost', siteHost.toLowerCase()])];
19
+ return `--host-resolver-rules=MAP * ~NOTFOUND, ${hosts.map((h) => `EXCLUDE ${h}`).join(', ')}`;
20
+ };
16
21
  const mask = (email) => email.replace(/^(.).*(@.*)$/, '$1***$2');
17
22
  /** Signs in through the app's own form and returns the browser state. The error never holds the password. */
18
23
  export async function signInWithPassword(baseUrl, account, page = {}) {
@@ -23,7 +28,8 @@ export async function signInWithPassword(baseUrl, account, page = {}) {
23
28
  const { chromium } = await loadPlaywright();
24
29
  const browser = await chromium.launch({
25
30
  headless: true,
26
- args: ['--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE localhost']
31
+ // The site's own host is reachable too (a live site is not on this machine); every other host is unresolvable
32
+ args: [hostResolverRules(new URL(baseUrl).hostname)]
27
33
  });
28
34
  try {
29
35
  const ctx = await browser.newContext({ baseURL: baseUrl, serviceWorkers: 'block' });