@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
@@ -3,26 +3,107 @@
3
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
4
  // -- DEP-MODULES -- //
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
- import { join } from 'node:path';
6
+ import { basename, join } from 'node:path';
7
7
  // -- MODULES -- //
8
8
  import { appendAudit, verifyAudit } from './audit.js';
9
9
  import { bindFolder } from './bind.js';
10
10
  import { BoardError, RunnerBoard } from './board.js';
11
- import { ensureBrowser, launchBrowserServer } from './browser.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';
12
16
  import { lintCheck } from './guard.js';
13
17
  import { assertName, ORG_SLUG, runsDir, scriptPath, scriptsDir } from './paths.js';
18
+ import { liveSiteGuardOptions } from './live.js';
19
+ import { pictureUploadAllowed, savedStateFor } from './login.js';
14
20
  import { startPreview } from './preview.js';
15
21
  import { redact } from './redact.js';
16
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';
17
26
  import { runCheckInSandbox } from './sandbox.js';
18
27
  import { installSignalHandlers, RunScope, sweepOrphans } from './scope.js';
19
28
  import { signInWithPassword, stateNeedles } from './signin.js';
20
29
  // -- CONSTANTS -- //
21
30
  const BEAT_EVERY_MS = 30_000;
22
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;
23
34
  // -- FUNCTIONS -- //
24
- /** The slots header: this process takes one run at a time. */
25
- const slotsHeader = (busy) => `1:${busy}`;
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
+ }
26
107
  function auditHeaders() {
27
108
  try {
28
109
  const v = verifyAudit();
@@ -77,6 +158,14 @@ export async function runCheck(o) {
77
158
  let beat = null;
78
159
  let uninstall = () => undefined;
79
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);
80
169
  try {
81
170
  if (o.claimed)
82
171
  claimed = true;
@@ -85,7 +174,6 @@ export async function runCheck(o) {
85
174
  assertName(o.item, 'item reference');
86
175
  assertName(o.key, 'check key');
87
176
  // -- THE FOLDER AND THE SCRIPT -- //
88
- const cfg = resolveProjectConfig(o.dir);
89
177
  const script = scriptPath(o.org, o.item, o.key);
90
178
  if (!existsSync(script))
91
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}`);
@@ -93,14 +181,11 @@ export async function runCheck(o) {
93
181
  const unsafe = lintCheck(source);
94
182
  if (unsafe.length)
95
183
  return stop(`This check uses ${unsafe.join(', ')}, which a check has no business doing, so it was not run. Rewrite it.`);
96
- // Running a check in a folder is the approval of that folder (and of the files that decide what runs) for `check watch`
97
- const projectKey = /^([A-Z][A-Z0-9]{1,9})-\d{8}-\d{4}/.exec(o.item)?.[1];
98
- if (projectKey)
99
- bindFolder(o.org, projectKey, cfg.repo, pinFiles(cfg.repo));
100
184
  // -- THE RUN -- //
101
185
  board = new RunnerBoard(o.server, o.runnerToken, o.fetch, () => ({
102
- 'x-afb-tool-slots': slotsHeader(claimed ? 1 : 0),
103
- ...auditHeaders()
186
+ 'x-afb-tool-slots': slotsHeader(),
187
+ ...auditHeaders(),
188
+ ...registrationsHeaderFor(o.org)
104
189
  }));
105
190
  await sweepOrphans(o.log);
106
191
  runId = o.claimed?.id ?? o.runId ?? (await requestRunOnBoard(o));
@@ -108,7 +193,7 @@ export async function runCheck(o) {
108
193
  if (!job)
109
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);
110
195
  claimed = true;
111
- appendAudit('run-claimed', { run: runId });
196
+ auditSafely(o.log, 'run-claimed', { run: runId });
112
197
  if (job.item.ref !== o.item || job.check.key !== o.key) {
113
198
  await board.release([runId]).catch(() => undefined);
114
199
  claimed = false;
@@ -117,30 +202,81 @@ export async function runCheck(o) {
117
202
  const thisBoard = board;
118
203
  const thisRun = runId;
119
204
  const abort = new AbortController();
120
- beat = setInterval(() => {
121
- thisBoard.beat(thisRun).catch((e) => {
122
- // The board gave up on the run: stop working on it
123
- if (e instanceof BoardError && e.status === 409)
124
- abort.abort();
125
- });
126
- }, BEAT_EVERY_MS);
127
- beat.unref();
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
+ }
128
229
  scope = new RunScope(runId, o.log);
129
230
  scope.start();
130
231
  const thisScope = scope;
131
- uninstall = installSignalHandlers(scope, () => {
132
- void thisBoard
133
- .finish(thisRun, { result: 'error', reason: 'The run was stopped before it finished.' })
134
- .catch(() => undefined)
135
- .finally(() => process.exit(2));
136
- });
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 });
137
250
  await ensureBrowser(o.log);
138
- const preview = await startPreview(cfg, { runId, scope, log: 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);
139
259
  if (preview.note)
140
260
  o.log(preview.note);
141
- // -- SIGN IN AS THE RECIPE'S TEST USER -- //
261
+ // -- SIGN IN: A SAVED SIGN IN OF THIS REGISTRATION, ELSE THE RECIPE'S TEST USER -- //
142
262
  let state = null;
143
- if (preview.testUser) {
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) {
144
280
  o.log(`Signing in as ${preview.testUser.email.replace(/^(.).*(@.*)$/, '$1***$2')}...`);
145
281
  state = await signInWithPassword(preview.baseUrl, preview.testUser);
146
282
  }
@@ -149,9 +285,22 @@ export async function runCheck(o) {
149
285
  ...(preview.testUser ? [preview.testUser.password, preview.testUser.email] : []),
150
286
  ...stateNeedles(state)
151
287
  ];
288
+ needlesAll.push(...needles);
152
289
  const runDir = join(runsDir(), runId);
153
- const browser = await launchBrowserServer({ headed: o.headed });
154
- thisScope.add('browser', () => browser.close());
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());
155
304
  const childJob = {
156
305
  baseUrl: preview.baseUrl,
157
306
  ws: browser.wsEndpoint(),
@@ -159,7 +308,7 @@ export async function runCheck(o) {
159
308
  checkFile: script,
160
309
  runDir,
161
310
  viewport: { width: 1280, height: 800 },
162
- extraHosts: [],
311
+ extraHosts,
163
312
  needles
164
313
  };
165
314
  const ran = await runCheckInSandbox({
@@ -169,21 +318,39 @@ export async function runCheck(o) {
169
318
  signal: abort.signal
170
319
  });
171
320
  if (!ran.result) {
172
- const why = redact(ran.stderr || 'The check did not finish.', needles).slice(-1200);
173
- await board.finish(runId, {
174
- result: 'error',
175
- reason: `The check did not finish. ${why}`.slice(0, 1900)
176
- });
177
- finished = true;
178
- claimed = false;
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
+ }
179
333
  return stop(`The check did not finish. ${why}`, runId);
180
334
  }
181
335
  // -- REPORT -- //
182
336
  const ids = [];
183
- for (const s of pickShots(ran.result.steps)) {
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) : []) {
184
342
  if (!s.shot)
185
343
  continue;
186
- const bytes = readFileSync(join(runDir, 'shots', s.shot));
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;
187
354
  // A picture that holds a secret (found as bytes or text) is not uploaded
188
355
  if (needles.some((n) => n.length >= 6 && Buffer.from(bytes).includes(n))) {
189
356
  o.log(' A picture held a secret value, so it was not uploaded.');
@@ -196,23 +363,34 @@ export async function runCheck(o) {
196
363
  }));
197
364
  }
198
365
  const result = ran.result;
199
- const report = {
366
+ const wire = registration ? withBranch(registration) : null;
367
+ const report = sanitiseReport({
200
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
+ : {}),
201
376
  durationMs: Math.min(result.durationMs, 24 * 3600 * 1000),
202
377
  url: preview.baseUrl.slice(0, 300),
203
378
  commit: preview.commit,
204
379
  diffHash: preview.diffHash,
205
380
  ...(result.result === 'pass'
206
381
  ? {}
207
- : { reason: redact(result.reason ?? 'The check failed.', needles).slice(0, 1900) }),
382
+ : { reason: reasonFor(result.reason ?? 'The check failed.') }),
208
383
  ...(preview.commit ? {} : { unverified: true }),
209
- log: redact(result.log, needles).slice(0, 390_000),
384
+ log: redact(result.log, needlesAll).slice(0, 390_000),
210
385
  evidence: ids
211
- };
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);
212
390
  await board.finish(runId, report);
213
391
  finished = true;
214
392
  claimed = false;
215
- appendAudit('run-finished', { run: runId, result: result.result });
393
+ auditSafely(o.log, 'run-finished', { run: runId, result: result.result });
216
394
  return {
217
395
  exitCode: result.result === 'pass' ? 0 : 1,
218
396
  runId,
@@ -223,29 +401,41 @@ export async function runCheck(o) {
223
401
  };
224
402
  }
225
403
  catch (e) {
226
- const reason = e instanceof Error ? e.message : String(e);
227
- if (board && runId && claimed) {
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()) {
228
408
  await board
229
- .finish(runId, { result: 'error', reason: reason.slice(0, 1900) })
409
+ .finish(runId, { result: 'error', reason: reasonFor(reason) })
230
410
  .catch(() => undefined);
231
411
  finished = true;
232
- claimed = false;
233
412
  }
234
- return stop(reason, runId);
413
+ return stop(reasonFor(reason), runId);
235
414
  }
236
415
  finally {
237
416
  if (beat)
238
417
  clearInterval(beat);
418
+ if (limitTimer)
419
+ clearTimeout(limitTimer);
239
420
  uninstall();
240
421
  if (scope)
241
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);
242
432
  // A run that `check watch` already claimed must never be left running when this ends early (no script, a changed folder, a refusal)
243
- if (o.claimed && !finished) {
433
+ if (o.claimed && !finished && !schedulerOwns()) {
244
434
  const b = board ?? new RunnerBoard(o.server, o.runnerToken, o.fetch);
245
435
  await b
246
436
  .finish(o.claimed.id, {
247
437
  result: 'error',
248
- reason: (lastStop || 'The run stopped early.').slice(0, 1900)
438
+ reason: reasonFor(lastStop || 'The run stopped early.')
249
439
  })
250
440
  .catch(() => undefined);
251
441
  }
@@ -5,11 +5,12 @@
5
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
6
  // -- DEP-MODULES -- //
7
7
  import { spawn } from 'node:child_process';
8
- import { existsSync, readFileSync } from 'node:fs';
9
- import { dirname, join } from 'node:path';
8
+ import { existsSync } from 'node:fs';
9
+ import { basename, dirname, join } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  // -- MODULES -- //
12
12
  import { playwrightRuntimeDir } from './browser.js';
13
+ import { MAX_RESULT_BYTES, findLink, isPlainFile, readPlainFile } from './evidence.js';
13
14
  import { ensureDir, isInside, runsDir } from './paths.js';
14
15
  import { redact } from './redact.js';
15
16
  import { killTree } from './scope.js';
@@ -105,7 +106,7 @@ export async function runNodeInSandbox(opts) {
105
106
  }
106
107
  const redactDeep = (text, needles, max) => redact(String(text ?? ''), needles).slice(0, max);
107
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. */
108
- function cleanResult(raw, needles) {
109
+ export function cleanResult(raw, needles, shotsDir) {
109
110
  if (typeof raw !== 'object' || raw === null)
110
111
  return null;
111
112
  const r = raw;
@@ -122,7 +123,10 @@ function cleanResult(raw, needles) {
122
123
  name: redactDeep(s.name, needles, 200),
123
124
  status: s.status === 'pass' || s.status === 'fail' ? s.status : 'shot',
124
125
  ms: Number(s.ms) || 0,
125
- shot: SHOT.test(String(s.shot ?? '')) ? String(s.shot) : null,
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,
126
130
  ...(s.error ? { error: redactDeep(s.error, needles, 500) } : {})
127
131
  })),
128
132
  log: redactDeep(r.log, needles, 20_000),
@@ -136,6 +140,12 @@ export async function runCheckInSandbox({ job, scriptsRoot, log, signal }) {
136
140
  throw new Error('That check is not inside the scripts folder, so it was not run.');
137
141
  if (!isInside(runsDir(), job.runDir))
138
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
+ }
139
149
  const pwDir = playwrightRuntimeDir();
140
150
  if (!pwDir)
141
151
  throw new Error('Playwright is not installed yet. Run "agent-flow-board check setup" and it will install it.');
@@ -169,15 +179,27 @@ export async function runCheckInSandbox({ job, scriptsRoot, log, signal }) {
169
179
  }
170
180
  }
171
181
  });
172
- const stderr = redact(run.stderr, needles);
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
+ }
173
190
  const file = join(job.runDir, 'result.json');
174
191
  let result = null;
175
192
  if (existsSync(file)) {
176
- try {
177
- result = cleanResult(JSON.parse(readFileSync(file, 'utf8')), needles);
178
- }
179
- catch {
180
- result = null;
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
+ }
181
203
  }
182
204
  }
183
205
  if (run.timedOut)