@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.
- package/README.md +29 -1
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +126 -0
- package/dist/checks/bind.js +100 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +275 -0
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +267 -0
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +42 -0
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +212 -0
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -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 +207 -0
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +85 -0
- package/dist/checks/preview.js +577 -0
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +246 -0
- package/dist/checks/redact.js +52 -0
- 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 +443 -0
- package/dist/checks/sandbox.js +208 -0
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +383 -0
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +79 -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/types.js +2 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +228 -0
- package/dist/checks/wire.js +87 -0
- package/dist/cli.js +18 -1
- package/dist/hooks/adapters.js +3 -1
- package/dist/hooks/call.js +6 -1
- package/dist/hooks/modVersion.js +26 -0
- 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
|
+
}
|