@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.
- package/README.md +26 -0
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +274 -0
- package/dist/checks/askBoard.js +143 -0
- package/dist/checks/audit.js +3 -2
- package/dist/checks/bind.js +3 -2
- package/dist/checks/board.js +30 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +137 -32
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/command.js +126 -87
- package/dist/checks/commands.js +315 -0
- package/dist/checks/connections.js +4 -3
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +655 -0
- package/dist/checks/envPolicy.js +50 -1
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +28 -4
- package/dist/checks/login.js +307 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +24 -0
- package/dist/checks/preview.js +418 -191
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +1 -1
- package/dist/checks/recipe.js +19 -5
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +243 -53
- package/dist/checks/sandbox.js +32 -10
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +119 -30
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +454 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +7 -1
- package/dist/checks/signinKey.js +104 -0
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +190 -63
- package/dist/checks/wire.js +87 -0
- package/package.json +1 -1
package/dist/checks/run.js
CHANGED
|
@@ -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
|
|
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
|
|
25
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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, {
|
|
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
|
|
261
|
+
// -- SIGN IN: A SAVED SIGN IN OF THIS REGISTRATION, ELSE THE RECIPE'S TEST USER -- //
|
|
142
262
|
let state = null;
|
|
143
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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 =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
382
|
+
: { reason: reasonFor(result.reason ?? 'The check failed.') }),
|
|
208
383
|
...(preview.commit ? {} : { unverified: true }),
|
|
209
|
-
log: redact(result.log,
|
|
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
|
-
|
|
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
|
|
227
|
-
|
|
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
|
|
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.')
|
|
438
|
+
reason: reasonFor(lastStop || 'The run stopped early.')
|
|
249
439
|
})
|
|
250
440
|
.catch(() => undefined);
|
|
251
441
|
}
|
package/dist/checks/sandbox.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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)
|