@agent-flow-board/bridge 0.1.0 → 0.3.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 (62) hide show
  1. package/README.md +29 -1
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +126 -0
  5. package/dist/checks/bind.js +100 -0
  6. package/dist/checks/board.js +107 -0
  7. package/dist/checks/broker.js +439 -0
  8. package/dist/checks/browser.js +275 -0
  9. package/dist/checks/browserpool.js +221 -0
  10. package/dist/checks/cache.js +710 -0
  11. package/dist/checks/child.js +211 -0
  12. package/dist/checks/command.js +267 -0
  13. package/dist/checks/commands.js +219 -0
  14. package/dist/checks/connect.js +79 -0
  15. package/dist/checks/connections.js +42 -0
  16. package/dist/checks/copy.js +320 -0
  17. package/dist/checks/dbpool.js +710 -0
  18. package/dist/checks/doctor.js +649 -0
  19. package/dist/checks/envPolicy.js +212 -0
  20. package/dist/checks/envrules.js +322 -0
  21. package/dist/checks/evidence.js +114 -0
  22. package/dist/checks/fsretry.js +29 -0
  23. package/dist/checks/guard.js +188 -0
  24. package/dist/checks/guardrails.js +155 -0
  25. package/dist/checks/importlegacy.js +300 -0
  26. package/dist/checks/install.js +150 -0
  27. package/dist/checks/live.js +76 -0
  28. package/dist/checks/lock.js +207 -0
  29. package/dist/checks/login.js +203 -0
  30. package/dist/checks/mailguard-hook.cjs +216 -0
  31. package/dist/checks/mailguard.js +98 -0
  32. package/dist/checks/paths.js +85 -0
  33. package/dist/checks/preview.js +577 -0
  34. package/dist/checks/profiles.js +110 -0
  35. package/dist/checks/provision.js +175 -0
  36. package/dist/checks/recipe.js +246 -0
  37. package/dist/checks/redact.js +52 -0
  38. package/dist/checks/registry.js +548 -0
  39. package/dist/checks/repoenv.js +162 -0
  40. package/dist/checks/roots.js +144 -0
  41. package/dist/checks/routing.js +68 -0
  42. package/dist/checks/run.js +443 -0
  43. package/dist/checks/sandbox.js +208 -0
  44. package/dist/checks/scheduler.js +383 -0
  45. package/dist/checks/scope.js +383 -0
  46. package/dist/checks/sealedNames.js +39 -0
  47. package/dist/checks/secrets.js +452 -0
  48. package/dist/checks/secretscmd.js +80 -0
  49. package/dist/checks/service.js +589 -0
  50. package/dist/checks/signin.js +79 -0
  51. package/dist/checks/singleflight.js +307 -0
  52. package/dist/checks/store.js +92 -0
  53. package/dist/checks/surface.js +124 -0
  54. package/dist/checks/types.js +2 -0
  55. package/dist/checks/warm.js +342 -0
  56. package/dist/checks/watch.js +228 -0
  57. package/dist/checks/wire.js +87 -0
  58. package/dist/cli.js +18 -1
  59. package/dist/hooks/adapters.js +3 -1
  60. package/dist/hooks/call.js +6 -1
  61. package/dist/hooks/modVersion.js +26 -0
  62. package/package.json +2 -2
@@ -0,0 +1,443 @@
1
+ // `check run <item> <key>`: ASK FOR THE RUN, CLAIM THAT RUN BY ITS ID, BUILD A PRIVATE PREVIEW, RUN THE CHECK IN A SANDBOX, REPORT
2
+ // The run is requested with the person's own sign in (the one the hooks and MCP use), claimed with this machine's runner token, and finished
3
+ // with it. The folder is the repo the agent is in: running a check there is the approval of that folder for `check watch`.
4
+ // -- DEP-MODULES -- //
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { basename, join } from 'node:path';
7
+ // -- MODULES -- //
8
+ import { appendAudit, verifyAudit } from './audit.js';
9
+ import { bindFolder } from './bind.js';
10
+ import { BoardError, RunnerBoard } from './board.js';
11
+ import { ensureBrowser } from './browser.js';
12
+ import { acquireBrowser } from './browserpool.js';
13
+ import { JPEG_MAGIC, MAX_SHOT_BYTES, readPlainFile } from './evidence.js';
14
+ import { acquireSlot, slotsHeader } from './broker.js';
15
+ import { closePoolIfIdle } from './dbpool.js';
16
+ import { lintCheck } from './guard.js';
17
+ import { assertName, ORG_SLUG, runsDir, scriptPath, scriptsDir } from './paths.js';
18
+ import { liveSiteGuardOptions } from './live.js';
19
+ import { pictureUploadAllowed, savedStateFor } from './login.js';
20
+ import { startPreview } from './preview.js';
21
+ import { redact } from './redact.js';
22
+ import { pinFiles, resolveProjectConfig } from './recipe.js';
23
+ import { configFor, listRegistrations, pinRegistration, pinState, readRegistrations } from './registry.js';
24
+ import { registrationHeader, routeRun } from './routing.js';
25
+ import { registrationsHeader, sanitiseReport, withBranch } from './wire.js';
26
+ import { runCheckInSandbox } from './sandbox.js';
27
+ import { installSignalHandlers, RunScope, sweepOrphans } from './scope.js';
28
+ import { signInWithPassword, stateNeedles } from './signin.js';
29
+ // -- CONSTANTS -- //
30
+ const BEAT_EVERY_MS = 30_000;
31
+ const MAX_EVIDENCE = 8;
32
+ /** A run started by hand (not by the worker, whose scheduler has its own limit) stops after this long. */
33
+ const DEFAULT_RUN_LIMIT_MS = 40 * 60_000;
34
+ // -- FUNCTIONS -- //
35
+ /** The run limit of a standalone run in ms: AFB_CHECKS_RUN_LIMIT_MS when it is a positive number, else 40 minutes. */
36
+ export function runLimitMs(env = process.env) {
37
+ const v = Number(env.AFB_CHECKS_RUN_LIMIT_MS);
38
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_RUN_LIMIT_MS;
39
+ }
40
+ /** The sentence for a run stopped by its limit. */
41
+ export function limitSentence(ms) {
42
+ return ms < 60_000
43
+ ? `The run took longer than ${Math.round(ms / 1000)} seconds and was stopped.`
44
+ : `The run took longer than ${Math.round(ms / 60_000)} minutes and was stopped.`;
45
+ }
46
+ /** Writes an audit line, and never lets a failure of the log change the outcome of a run. */
47
+ function auditSafely(log, action, detail) {
48
+ try {
49
+ appendAudit(action, detail);
50
+ }
51
+ catch {
52
+ log('The audit log could not be written for this run.');
53
+ }
54
+ }
55
+ /** The registrations of this organisation, for the board to route a run that names one (at most 16, refreshed by every call). */
56
+ function registrationsHeaderFor(org) {
57
+ try {
58
+ const value = registrationsHeader(org, listRegistrations(org).map(withBranch));
59
+ return value ? { 'x-afb-tool-registrations': value } : {};
60
+ }
61
+ catch {
62
+ return {};
63
+ }
64
+ }
65
+ /**
66
+ * The registration of a run: the one the board names, else the one asked for with --registration, else the check script's own header, else the board
67
+ * project's only registration. With none registered for the board project the folder of the current directory is used, as before registrations existed.
68
+ */
69
+ export function configForRun(o, job, source, log, fromWatch) {
70
+ const read = readRegistrations(o.org);
71
+ const all = read.registrations;
72
+ const mine = all.filter((r) => r.boardProject === job.project.key);
73
+ if (!mine.length) {
74
+ // A registration that is damaged or was changed by hand is not "no registration": the folder of the current directory is never taken in its place
75
+ if (read.problems.length)
76
+ throw new Error(`${read.problems.join(' ')} No other registration serves ${job.project.key}, so the run was not started. Register the project again with: agent-flow-board check project`);
77
+ const cfg = resolveProjectConfig(o.dir);
78
+ // Running a check in a folder is the approval of that folder (and of the files that decide what runs) for `check watch`
79
+ bindFolder(o.org, job.project.key, cfg.repo, pinFiles(cfg.repo));
80
+ return { cfg, registration: null, scriptRegistration: null };
81
+ }
82
+ const registration = routeRun({
83
+ org: o.org,
84
+ boardProject: job.project.key,
85
+ run: job.registration ?? o.registration ?? null,
86
+ script: source,
87
+ all
88
+ });
89
+ if (registration.repo) {
90
+ if (fromWatch) {
91
+ // The board's Run button never re-approves: a changed or missing pin needs a `check run` in the folder first
92
+ if (pinState(registration) !== 'ok')
93
+ throw new Error(`The files that decide what runs in ${registration.name} changed since they were approved. In that folder run: agent-flow-board check run <item> <key> --org ${o.org}`);
94
+ }
95
+ else {
96
+ const pin = pinRegistration(o.org, registration.name);
97
+ if (pin.changed)
98
+ log(`The files that decide what runs in ${registration.name} changed and were pinned again (${pin.files.length} files).`);
99
+ }
100
+ }
101
+ return {
102
+ cfg: configFor(registration),
103
+ registration,
104
+ scriptRegistration: registrationHeader(source)
105
+ };
106
+ }
107
+ function auditHeaders() {
108
+ try {
109
+ const v = verifyAudit();
110
+ return v.ok ? { 'x-afb-tool-audit': `${v.lines}:${v.head}` } : {};
111
+ }
112
+ catch {
113
+ return {};
114
+ }
115
+ }
116
+ /** Asks the board for a preview run of this check. Returns the run id, or throws one plain sentence. */
117
+ export async function requestRunOnBoard(o) {
118
+ if (!o.personalToken)
119
+ throw new Error(`Not signed in to ${o.org} on ${o.server}, so the run cannot be asked for. Run: agent-flow-board login --json --server ${o.server} --org ${o.org}, or ask for the run with check_run and pass --run <id>.`);
120
+ const res = await o.fetch(`${o.server}/api/v1/orgs/${encodeURIComponent(o.org)}/items/${encodeURIComponent(o.item)}/browser-checks/runs`, {
121
+ method: 'POST',
122
+ headers: {
123
+ authorization: `Bearer ${o.personalToken}`,
124
+ 'content-type': 'application/json'
125
+ },
126
+ body: JSON.stringify({
127
+ check: o.key,
128
+ target: 'preview',
129
+ ...(o.step ? { step: o.step } : {})
130
+ }),
131
+ signal: AbortSignal.timeout(30_000)
132
+ });
133
+ const body = (await res.json().catch(() => ({})));
134
+ if (!res.ok)
135
+ throw new Error(body.error?.message ?? `The board answered ${res.status} when asking for the run.`);
136
+ if (!body.run?.id)
137
+ throw new Error('The board has no machine online for this project. Run: agent-flow-board check connect --server <url> --org <slug> --json');
138
+ return body.run.id;
139
+ }
140
+ /** Up to MAX_EVIDENCE pictures, the last one always kept. */
141
+ function pickShots(steps) {
142
+ const wanted = steps.filter((s) => s.shot);
143
+ return wanted.length > MAX_EVIDENCE
144
+ ? [...wanted.slice(0, MAX_EVIDENCE - 1), wanted[wanted.length - 1]]
145
+ : wanted;
146
+ }
147
+ /** Runs one check end to end. Never throws: every failure is an outcome with a plain message. */
148
+ export async function runCheck(o) {
149
+ let lastStop = '';
150
+ let finished = false;
151
+ const stop = (message, runId = null) => {
152
+ lastStop = message;
153
+ return { exitCode: 2, runId, resultPath: null, message };
154
+ };
155
+ let runId = null;
156
+ let board = null;
157
+ let scope = null;
158
+ let beat = null;
159
+ let uninstall = () => undefined;
160
+ let claimed = false;
161
+ let slot = null;
162
+ // Everything the run must never print, filled in as it becomes known; the reasons that go to the board are redacted with it
163
+ const needlesAll = [];
164
+ let limitTimer = null;
165
+ let limitHit = false;
166
+ // A run the worker's scheduler holds is stopped, timed out or given up on by the scheduler, which gives it back or reports it by id
167
+ const schedulerOwns = () => Boolean(o.scheduled && o.signal?.aborted);
168
+ const reasonFor = (text) => redact(text, needlesAll).slice(0, 1900);
169
+ try {
170
+ if (o.claimed)
171
+ claimed = true;
172
+ if (!ORG_SLUG.test(o.org))
173
+ return stop('--org takes an organisation slug: lowercase letters, digits and dashes.');
174
+ assertName(o.item, 'item reference');
175
+ assertName(o.key, 'check key');
176
+ // -- THE FOLDER AND THE SCRIPT -- //
177
+ const script = scriptPath(o.org, o.item, o.key);
178
+ if (!existsSync(script))
179
+ return stop(`There is no check file for ${o.item} ${o.key} at ${script}. Write it there, or read the board's copy with: agent-flow-board check pull ${o.item} ${o.key} --org ${o.org}`);
180
+ const source = readFileSync(script, 'utf8');
181
+ const unsafe = lintCheck(source);
182
+ if (unsafe.length)
183
+ return stop(`This check uses ${unsafe.join(', ')}, which a check has no business doing, so it was not run. Rewrite it.`);
184
+ // -- THE RUN -- //
185
+ board = new RunnerBoard(o.server, o.runnerToken, o.fetch, () => ({
186
+ 'x-afb-tool-slots': slotsHeader(),
187
+ ...auditHeaders(),
188
+ ...registrationsHeaderFor(o.org)
189
+ }));
190
+ await sweepOrphans(o.log);
191
+ runId = o.claimed?.id ?? o.runId ?? (await requestRunOnBoard(o));
192
+ const job = o.claimed ?? (await board.claim(runId));
193
+ if (!job)
194
+ return stop(`The board did not give this machine the run ${runId}. It is not queued for this machine, or this machine does not serve that project (check connect --project), or another tool took it.`, runId);
195
+ claimed = true;
196
+ auditSafely(o.log, 'run-claimed', { run: runId });
197
+ if (job.item.ref !== o.item || job.check.key !== o.key) {
198
+ await board.release([runId]).catch(() => undefined);
199
+ claimed = false;
200
+ return stop(`The run ${runId} is for ${job.item.ref} ${job.check.key}, not ${o.item} ${o.key}, so it was given back.`, runId);
201
+ }
202
+ const thisBoard = board;
203
+ const thisRun = runId;
204
+ const abort = new AbortController();
205
+ // A run the worker's scheduler holds is beaten (and limited) by the scheduler from the moment of its claim; its stop reaches this run through the signal
206
+ if (o.signal) {
207
+ if (o.signal.aborted)
208
+ abort.abort();
209
+ else
210
+ o.signal.addEventListener('abort', () => abort.abort(), { once: true });
211
+ }
212
+ if (!o.scheduled) {
213
+ beat = setInterval(() => {
214
+ thisBoard.beat(thisRun).catch((e) => {
215
+ // The board gave up on the run: stop working on it
216
+ if (e instanceof BoardError && e.status === 409)
217
+ abort.abort();
218
+ });
219
+ }, BEAT_EVERY_MS);
220
+ beat.unref();
221
+ // A run started by hand has the same limit the worker gives its runs
222
+ const limit = runLimitMs();
223
+ limitTimer = setTimeout(() => {
224
+ limitHit = true;
225
+ abort.abort();
226
+ }, limit);
227
+ limitTimer.unref();
228
+ }
229
+ scope = new RunScope(runId, o.log);
230
+ scope.start();
231
+ const thisScope = scope;
232
+ // The worker's runs never install their own handlers: its stop reaches them through the signal, and each closes only its own scope. A handler per run
233
+ // would finish every run as an error and end the whole process before the scheduler has given the others back
234
+ if (!o.scheduled)
235
+ uninstall = installSignalHandlers(scope, () => {
236
+ void thisBoard
237
+ .finish(thisRun, { result: 'error', reason: 'The run was stopped before it finished.' })
238
+ .catch(() => undefined)
239
+ .finally(() => process.exit(2));
240
+ });
241
+ // -- WHICH REGISTRATION, AND A PLACE ON THIS MACHINE -- //
242
+ // A registration the board or the script names is chosen here, never guessed; a refusal ends the run with its code and the list
243
+ const routed = configForRun(o, job, source, o.log, Boolean(o.claimed));
244
+ const cfg = routed.cfg;
245
+ const registration = routed.registration;
246
+ // The heartbeat already runs, so a run that waits for a slot, the preflight or another run's build keeps beating and never looks stuck to the board
247
+ // (a scheduled run already holds its slot)
248
+ if (!o.scheduled)
249
+ slot = await acquireSlot({ runId, kind: 'check', registration: registration?.name ?? job.project.key }, { signal: abort.signal });
250
+ await ensureBrowser(o.log);
251
+ const preview = await startPreview(cfg, {
252
+ runId,
253
+ scope,
254
+ log: o.log,
255
+ signal: abort.signal,
256
+ needles: needlesAll
257
+ });
258
+ needlesAll.push(...preview.secrets);
259
+ if (preview.note)
260
+ o.log(preview.note);
261
+ // -- SIGN IN: A SAVED SIGN IN OF THIS REGISTRATION, ELSE THE RECIPE'S TEST USER -- //
262
+ let state = null;
263
+ // Only a sign in the owner SAVED counts for the picture rule: the recipe's test user belongs to a throwaway preview and holds nothing worth hiding
264
+ let usedSavedSignIn = false;
265
+ if (registration) {
266
+ const saved = savedStateFor({
267
+ org: o.org,
268
+ registration: registration.name,
269
+ scriptRegistration: routed.scriptRegistration,
270
+ bindKey: new URL(preview.baseUrl).origin
271
+ });
272
+ if (saved) {
273
+ state = saved.profile?.state ?? null;
274
+ usedSavedSignIn = state !== null;
275
+ if (saved.note)
276
+ o.log(saved.note);
277
+ }
278
+ }
279
+ if (!state && preview.testUser) {
280
+ o.log(`Signing in as ${preview.testUser.email.replace(/^(.).*(@.*)$/, '$1***$2')}...`);
281
+ state = await signInWithPassword(preview.baseUrl, preview.testUser);
282
+ }
283
+ const needles = [
284
+ ...preview.secrets,
285
+ ...(preview.testUser ? [preview.testUser.password, preview.testUser.email] : []),
286
+ ...stateNeedles(state)
287
+ ];
288
+ needlesAll.push(...needles);
289
+ const runDir = join(runsDir(), runId);
290
+ // The browser may reach this machine and the hosts of this registration only (a live site's hosts, the sealed test project's host); a live
291
+ // site or a signed in run always has its own browser, and a live site never allows writes
292
+ const extraHosts = [
293
+ ...new Set([
294
+ ...(cfg.url ? liveSiteGuardOptions(cfg).extraHosts : (cfg.extraHosts ?? [])),
295
+ ...(preview.extraHosts ?? [])
296
+ ])
297
+ ];
298
+ const browser = await acquireBrowser({
299
+ hosts: extraHosts,
300
+ headed: o.headed,
301
+ dedicated: Boolean(cfg.url) || state !== null
302
+ });
303
+ thisScope.add('browser', () => browser.release());
304
+ const childJob = {
305
+ baseUrl: preview.baseUrl,
306
+ ws: browser.wsEndpoint(),
307
+ storageState: state,
308
+ checkFile: script,
309
+ runDir,
310
+ viewport: { width: 1280, height: 800 },
311
+ extraHosts,
312
+ needles
313
+ };
314
+ const ran = await runCheckInSandbox({
315
+ job: childJob,
316
+ scriptsRoot: scriptsDir(),
317
+ log: o.log,
318
+ signal: abort.signal
319
+ });
320
+ if (!ran.result) {
321
+ const why = limitHit
322
+ ? limitSentence(runLimitMs())
323
+ : redact(ran.stderr || 'The check did not finish.', needlesAll).slice(-1200);
324
+ // A run the scheduler stopped is given back by the scheduler, not reported here
325
+ if (!schedulerOwns()) {
326
+ await board.finish(runId, {
327
+ result: 'error',
328
+ reason: reasonFor(`The check did not finish. ${why}`)
329
+ });
330
+ finished = true;
331
+ claimed = false;
332
+ }
333
+ return stop(`The check did not finish. ${why}`, runId);
334
+ }
335
+ // -- REPORT -- //
336
+ const ids = [];
337
+ // Pictures of a signed in run are not uploaded unless the owner opted in for the registration: a compressed picture cannot be searched for a secret
338
+ const uploadPictures = pictureUploadAllowed(registration ?? { uploadSignedInPictures: false }, usedSavedSignIn);
339
+ if (!uploadPictures)
340
+ o.log('The pictures of a signed in run are not uploaded for this registration.');
341
+ for (const s of uploadPictures ? pickShots(ran.result.steps) : []) {
342
+ if (!s.shot)
343
+ continue;
344
+ // The child wrote this file, so it is only read when it is a plain JPEG inside the shots folder (never a link it planted to a key file)
345
+ const read = readPlainFile(join(runDir, 'shots'), s.shot, {
346
+ maxBytes: MAX_SHOT_BYTES,
347
+ magic: JPEG_MAGIC
348
+ });
349
+ if ('refused' in read) {
350
+ o.log(` A picture was not uploaded: ${read.refused}.`);
351
+ continue;
352
+ }
353
+ const bytes = read.bytes;
354
+ // A picture that holds a secret (found as bytes or text) is not uploaded
355
+ if (needles.some((n) => n.length >= 6 && Buffer.from(bytes).includes(n))) {
356
+ o.log(' A picture held a secret value, so it was not uploaded.');
357
+ continue;
358
+ }
359
+ ids.push(await board.upload(runId, {
360
+ filename: s.shot,
361
+ caption: `${s.status === 'fail' ? 'Failed: ' : ''}${s.name}`.slice(0, 120),
362
+ bytes
363
+ }));
364
+ }
365
+ const result = ran.result;
366
+ const wire = registration ? withBranch(registration) : null;
367
+ const report = sanitiseReport({
368
+ result: result.result,
369
+ ...(registration
370
+ ? {
371
+ registration: registration.name,
372
+ ...(registration.repo ? { repoName: basename(registration.repo) } : {}),
373
+ ...(wire?.branch ? { branch: wire.branch } : {})
374
+ }
375
+ : {}),
376
+ durationMs: Math.min(result.durationMs, 24 * 3600 * 1000),
377
+ url: preview.baseUrl.slice(0, 300),
378
+ commit: preview.commit,
379
+ diffHash: preview.diffHash,
380
+ ...(result.result === 'pass'
381
+ ? {}
382
+ : { reason: reasonFor(result.reason ?? 'The check failed.') }),
383
+ ...(preview.commit ? {} : { unverified: true }),
384
+ log: redact(result.log, needlesAll).slice(0, 390_000),
385
+ evidence: ids
386
+ });
387
+ // A run the scheduler stopped is given back by the scheduler: what it found is not reported
388
+ if (schedulerOwns())
389
+ return stop('The run was stopped.', runId);
390
+ await board.finish(runId, report);
391
+ finished = true;
392
+ claimed = false;
393
+ auditSafely(o.log, 'run-finished', { run: runId, result: result.result });
394
+ return {
395
+ exitCode: result.result === 'pass' ? 0 : 1,
396
+ runId,
397
+ resultPath: join(runDir, 'result.json'),
398
+ message: result.result === 'pass'
399
+ ? 'The check passed.'
400
+ : `The check failed: ${result.reason ?? 'see the log'}`
401
+ };
402
+ }
403
+ catch (e) {
404
+ const said = e instanceof Error ? e.message : String(e);
405
+ const reason = limitHit ? limitSentence(runLimitMs()) : said;
406
+ // Nothing that reaches the board is unredacted: not a build log tail, not the message of an error
407
+ if (board && runId && claimed && !schedulerOwns()) {
408
+ await board
409
+ .finish(runId, { result: 'error', reason: reasonFor(reason) })
410
+ .catch(() => undefined);
411
+ finished = true;
412
+ }
413
+ return stop(reasonFor(reason), runId);
414
+ }
415
+ finally {
416
+ if (beat)
417
+ clearInterval(beat);
418
+ if (limitTimer)
419
+ clearTimeout(limitTimer);
420
+ uninstall();
421
+ if (scope)
422
+ await scope.close();
423
+ // The slot goes back to the machine, and a pool container that no run needs any more is removed (a worker keeps it warm)
424
+ try {
425
+ await slot?.release();
426
+ }
427
+ catch {
428
+ /* A slot that cannot be released is cleared by its stale heartbeat */
429
+ }
430
+ if (!o.claimed)
431
+ await closePoolIfIdle().catch(() => undefined);
432
+ // A run that `check watch` already claimed must never be left running when this ends early (no script, a changed folder, a refusal)
433
+ if (o.claimed && !finished && !schedulerOwns()) {
434
+ const b = board ?? new RunnerBoard(o.server, o.runnerToken, o.fetch);
435
+ await b
436
+ .finish(o.claimed.id, {
437
+ result: 'error',
438
+ reason: reasonFor(lastStop || 'The run stopped early.')
439
+ })
440
+ .catch(() => undefined);
441
+ }
442
+ }
443
+ }
@@ -0,0 +1,208 @@
1
+ // THE SANDBOX OF A CHECK: A SEPARATE NODE PROCESS STARTED WITH THE PERMISSION MODEL ON.
2
+ // A check script is arbitrary Node code written by an agent, and board text can talk an agent into writing anything. The bridge keeps its
3
+ // sign-in (the key file and the token files) in the bridge home, and the check state in its checks folder. So the child can read ONLY the
4
+ // scripts folder, its own run folder, the folder of its own code and the Playwright runtime, and can write ONLY its run folder. It starts no
5
+ // program and no worker, loads no addon, and gets an environment of PATH, SystemRoot, TEMP, TMP and AFB_PLAYWRIGHT_DIR and nothing else.
6
+ // -- DEP-MODULES -- //
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync } from 'node:fs';
9
+ import { basename, dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ // -- MODULES -- //
12
+ import { playwrightRuntimeDir } from './browser.js';
13
+ import { MAX_RESULT_BYTES, findLink, isPlainFile, readPlainFile } from './evidence.js';
14
+ import { ensureDir, isInside, runsDir } from './paths.js';
15
+ import { redact } from './redact.js';
16
+ import { killTree } from './scope.js';
17
+ // -- CONSTANTS -- //
18
+ /** The hard limit of a whole check run. The child has its own 2 minute limit for the check itself, this is the backstop. */
19
+ export const RUN_TIMEOUT_MS = 5 * 60_000;
20
+ const SHOT = /^\d{2}\.jpg$/;
21
+ const MAX_STDERR = 4000;
22
+ const MAX_STDOUT = 2_000_000;
23
+ // -- FUNCTIONS -- //
24
+ /** The name of the permission flag of this Node: --permission since Node 23.5, --experimental-permission before. Null when it has neither. */
25
+ export function permissionFlag() {
26
+ const flags = process.allowedNodeEnvironmentFlags;
27
+ if (flags.has('--permission'))
28
+ return '--permission';
29
+ if (flags.has('--experimental-permission'))
30
+ return '--experimental-permission';
31
+ return null;
32
+ }
33
+ /** The environment of a sandboxed process: the least a Node program needs, a temp folder inside the run folder, and what the caller adds. */
34
+ export function sandboxEnv(tmpDir, extra = {}) {
35
+ const env = {};
36
+ if (process.env.PATH)
37
+ env.PATH = process.env.PATH;
38
+ if (process.env.SystemRoot)
39
+ env.SystemRoot = process.env.SystemRoot;
40
+ env.TEMP = tmpDir;
41
+ env.TMP = tmpDir;
42
+ return { ...env, ...extra };
43
+ }
44
+ /** Runs a Node script with the permission model on: it reads readDirs, writes writeDir, and starts nothing. */
45
+ export async function runNodeInSandbox(opts) {
46
+ const flag = permissionFlag();
47
+ if (!flag)
48
+ throw new Error(`This Node (${process.version}) has no permission model, so a check cannot be run safely. Use Node 22 or newer.`);
49
+ const args = [flag, ...opts.readDirs.map((d) => `--allow-fs-read=${d}`)];
50
+ if (opts.writeDir)
51
+ args.push(`--allow-fs-write=${opts.writeDir}`);
52
+ args.push(opts.entry);
53
+ const child = spawn(process.execPath, args, {
54
+ cwd: opts.writeDir ?? dirname(opts.entry),
55
+ env: opts.env,
56
+ windowsHide: true,
57
+ stdio: ['pipe', 'pipe', 'pipe'],
58
+ shell: false
59
+ });
60
+ let stdout = '';
61
+ let stderr = '';
62
+ let buf = '';
63
+ let timedOut = false;
64
+ const stop = () => {
65
+ if (child.pid !== undefined)
66
+ void Promise.resolve(killTree(child.pid)).catch(() => { });
67
+ child.kill();
68
+ };
69
+ const timer = setTimeout(() => {
70
+ timedOut = true;
71
+ stop();
72
+ }, opts.timeoutMs);
73
+ const onAbort = () => stop();
74
+ if (opts.signal) {
75
+ if (opts.signal.aborted)
76
+ stop();
77
+ else
78
+ opts.signal.addEventListener('abort', onAbort, { once: true });
79
+ }
80
+ child.stdout.on('data', (d) => {
81
+ const text = d.toString('utf8');
82
+ if (stdout.length < MAX_STDOUT)
83
+ stdout += text;
84
+ buf += text;
85
+ let i;
86
+ while ((i = buf.indexOf('\n')) >= 0) {
87
+ const line = buf.slice(0, i).replace(/\r$/, '');
88
+ buf = buf.slice(i + 1);
89
+ opts.onLine?.(line);
90
+ }
91
+ });
92
+ child.stderr.on('data', (d) => {
93
+ stderr = (stderr + d.toString('utf8')).slice(-MAX_STDERR);
94
+ });
95
+ // A child that dies before reading its stdin must not crash the parent
96
+ child.stdin.on('error', () => { });
97
+ const exitCode = await new Promise((resolve, reject) => {
98
+ child.on('error', reject);
99
+ child.on('close', (code) => resolve(code));
100
+ child.stdin.end(opts.stdin ?? '');
101
+ }).finally(() => {
102
+ clearTimeout(timer);
103
+ opts.signal?.removeEventListener('abort', onAbort);
104
+ });
105
+ return { exitCode, stdout, stderr, timedOut };
106
+ }
107
+ const redactDeep = (text, needles, max) => redact(String(text ?? ''), needles).slice(0, max);
108
+ /** The result the child wrote is data, not trusted: its shape is rebuilt, names are checked and every text goes through the redaction again. */
109
+ export function cleanResult(raw, needles, shotsDir) {
110
+ if (typeof raw !== 'object' || raw === null)
111
+ return null;
112
+ const r = raw;
113
+ const steps = Array.isArray(r.steps) ? r.steps : [];
114
+ return {
115
+ schema: 1,
116
+ result: r.result === 'pass' ? 'pass' : 'fail',
117
+ reason: typeof r.reason === 'string' && r.reason ? redactDeep(r.reason, needles, 700) : null,
118
+ durationMs: Number(r.durationMs) || 0,
119
+ steps: steps
120
+ .filter((s) => typeof s === 'object' && s !== null)
121
+ .slice(0, 200)
122
+ .map((s) => ({
123
+ name: redactDeep(s.name, needles, 200),
124
+ status: s.status === 'pass' || s.status === 'fail' ? s.status : 'shot',
125
+ ms: Number(s.ms) || 0,
126
+ // A picture that is not a plain file (a link the child planted) is dropped, not reported
127
+ shot: SHOT.test(String(s.shot ?? '')) && (!shotsDir || isPlainFile(shotsDir, String(s.shot)))
128
+ ? String(s.shot)
129
+ : null,
130
+ ...(s.error ? { error: redactDeep(s.error, needles, 500) } : {})
131
+ })),
132
+ log: redactDeep(r.log, needles, 20_000),
133
+ blockedWrites: Number(r.blockedWrites) || 0,
134
+ blockedOutside: Number(r.blockedOutside) || 0
135
+ };
136
+ }
137
+ /** Runs one check script in the sandbox and returns what it wrote. The browser is already running; the job names its address. */
138
+ export async function runCheckInSandbox({ job, scriptsRoot, log, signal }) {
139
+ if (!isInside(scriptsRoot, job.checkFile))
140
+ throw new Error('That check is not inside the scripts folder, so it was not run.');
141
+ if (!isInside(runsDir(), job.runDir))
142
+ throw new Error('The run folder is not inside the runs folder, so the check was not run.');
143
+ // The permission model follows links, so a link in a folder the check may read or write would reach files outside it: none is allowed there
144
+ for (const folder of [job.runDir, scriptsRoot]) {
145
+ const link = findLink(folder);
146
+ if (link)
147
+ throw new Error(`There is a link or a file with several names in a folder the check may use (${basename(link)}), so the check was not run. Remove it.`);
148
+ }
149
+ const pwDir = playwrightRuntimeDir();
150
+ if (!pwDir)
151
+ throw new Error('Playwright is not installed yet. Run "agent-flow-board check setup" and it will install it.');
152
+ const childFile = join(dirname(fileURLToPath(import.meta.url)), 'child.js');
153
+ if (!existsSync(childFile))
154
+ throw new Error('The check runner (child.js) is missing from this install. Build the bridge again.');
155
+ const needles = job.needles ?? [];
156
+ const tmpDir = ensureDir(join(job.runDir, 'tmp'));
157
+ const run = await runNodeInSandbox({
158
+ entry: childFile,
159
+ readDirs: [scriptsRoot, job.runDir, dirname(childFile), pwDir],
160
+ writeDir: job.runDir,
161
+ env: sandboxEnv(tmpDir, { AFB_PLAYWRIGHT_DIR: pwDir }),
162
+ stdin: JSON.stringify(job),
163
+ timeoutMs: RUN_TIMEOUT_MS,
164
+ signal,
165
+ onLine: (line) => {
166
+ if (!line.startsWith('EVENT '))
167
+ return;
168
+ try {
169
+ const e = JSON.parse(line.slice(6));
170
+ if (e.type === 'step') {
171
+ const secs = Math.round((Number(e.ms) || 0) / 100) / 10;
172
+ log(redact(` ${e.status === 'pass' ? 'PASS' : 'FAIL'} ${String(e.name ?? '')} (${secs}s)${e.error ? `: ${String(e.error)}` : ''}`, needles));
173
+ }
174
+ else if (e.type === 'log')
175
+ log(redact(` ${String(e.text ?? '')}`, needles));
176
+ }
177
+ catch {
178
+ /* Not an event */
179
+ }
180
+ }
181
+ });
182
+ let stderr = redact(run.stderr, needles);
183
+ // A link made during the run is as bad as one made before it: nothing the check wrote is read
184
+ const planted = [job.runDir, scriptsRoot].map((d) => findLink(d)).find(Boolean);
185
+ if (planted) {
186
+ log('A link appeared in a folder the check may use, so what it wrote was not read.');
187
+ stderr = `A link appeared in the run folder (${basename(planted)}). ${stderr}`.slice(-4000);
188
+ return { exitCode: run.exitCode, stderr, result: null };
189
+ }
190
+ const file = join(job.runDir, 'result.json');
191
+ let result = null;
192
+ if (existsSync(file)) {
193
+ const read = readPlainFile(job.runDir, 'result.json', { maxBytes: MAX_RESULT_BYTES });
194
+ if ('refused' in read)
195
+ log(`The result file of the check was not read: ${read.refused}.`);
196
+ else {
197
+ try {
198
+ result = cleanResult(JSON.parse(read.bytes.toString('utf8')), needles, join(job.runDir, 'shots'));
199
+ }
200
+ catch {
201
+ result = null;
202
+ }
203
+ }
204
+ }
205
+ if (run.timedOut)
206
+ log('The check ran for 5 minutes and was stopped.');
207
+ return { exitCode: run.exitCode, stderr, result };
208
+ }