@agent-flow-board/bridge 0.1.0 → 0.2.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.
@@ -0,0 +1,253 @@
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 { 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, launchBrowserServer } from './browser.js';
12
+ import { lintCheck } from './guard.js';
13
+ import { assertName, ORG_SLUG, runsDir, scriptPath, scriptsDir } from './paths.js';
14
+ import { startPreview } from './preview.js';
15
+ import { redact } from './redact.js';
16
+ import { pinFiles, resolveProjectConfig } from './recipe.js';
17
+ import { runCheckInSandbox } from './sandbox.js';
18
+ import { installSignalHandlers, RunScope, sweepOrphans } from './scope.js';
19
+ import { signInWithPassword, stateNeedles } from './signin.js';
20
+ // -- CONSTANTS -- //
21
+ const BEAT_EVERY_MS = 30_000;
22
+ const MAX_EVIDENCE = 8;
23
+ // -- FUNCTIONS -- //
24
+ /** The slots header: this process takes one run at a time. */
25
+ const slotsHeader = (busy) => `1:${busy}`;
26
+ function auditHeaders() {
27
+ try {
28
+ const v = verifyAudit();
29
+ return v.ok ? { 'x-afb-tool-audit': `${v.lines}:${v.head}` } : {};
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ /** Asks the board for a preview run of this check. Returns the run id, or throws one plain sentence. */
36
+ export async function requestRunOnBoard(o) {
37
+ if (!o.personalToken)
38
+ 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>.`);
39
+ const res = await o.fetch(`${o.server}/api/v1/orgs/${encodeURIComponent(o.org)}/items/${encodeURIComponent(o.item)}/browser-checks/runs`, {
40
+ method: 'POST',
41
+ headers: {
42
+ authorization: `Bearer ${o.personalToken}`,
43
+ 'content-type': 'application/json'
44
+ },
45
+ body: JSON.stringify({
46
+ check: o.key,
47
+ target: 'preview',
48
+ ...(o.step ? { step: o.step } : {})
49
+ }),
50
+ signal: AbortSignal.timeout(30_000)
51
+ });
52
+ const body = (await res.json().catch(() => ({})));
53
+ if (!res.ok)
54
+ throw new Error(body.error?.message ?? `The board answered ${res.status} when asking for the run.`);
55
+ if (!body.run?.id)
56
+ throw new Error('The board has no machine online for this project. Run: agent-flow-board check connect --server <url> --org <slug> --json');
57
+ return body.run.id;
58
+ }
59
+ /** Up to MAX_EVIDENCE pictures, the last one always kept. */
60
+ function pickShots(steps) {
61
+ const wanted = steps.filter((s) => s.shot);
62
+ return wanted.length > MAX_EVIDENCE
63
+ ? [...wanted.slice(0, MAX_EVIDENCE - 1), wanted[wanted.length - 1]]
64
+ : wanted;
65
+ }
66
+ /** Runs one check end to end. Never throws: every failure is an outcome with a plain message. */
67
+ export async function runCheck(o) {
68
+ let lastStop = '';
69
+ let finished = false;
70
+ const stop = (message, runId = null) => {
71
+ lastStop = message;
72
+ return { exitCode: 2, runId, resultPath: null, message };
73
+ };
74
+ let runId = null;
75
+ let board = null;
76
+ let scope = null;
77
+ let beat = null;
78
+ let uninstall = () => undefined;
79
+ let claimed = false;
80
+ try {
81
+ if (o.claimed)
82
+ claimed = true;
83
+ if (!ORG_SLUG.test(o.org))
84
+ return stop('--org takes an organisation slug: lowercase letters, digits and dashes.');
85
+ assertName(o.item, 'item reference');
86
+ assertName(o.key, 'check key');
87
+ // -- THE FOLDER AND THE SCRIPT -- //
88
+ const cfg = resolveProjectConfig(o.dir);
89
+ const script = scriptPath(o.org, o.item, o.key);
90
+ if (!existsSync(script))
91
+ 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}`);
92
+ const source = readFileSync(script, 'utf8');
93
+ const unsafe = lintCheck(source);
94
+ if (unsafe.length)
95
+ 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
+ // -- THE RUN -- //
101
+ board = new RunnerBoard(o.server, o.runnerToken, o.fetch, () => ({
102
+ 'x-afb-tool-slots': slotsHeader(claimed ? 1 : 0),
103
+ ...auditHeaders()
104
+ }));
105
+ await sweepOrphans(o.log);
106
+ runId = o.claimed?.id ?? o.runId ?? (await requestRunOnBoard(o));
107
+ const job = o.claimed ?? (await board.claim(runId));
108
+ if (!job)
109
+ 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
+ claimed = true;
111
+ appendAudit('run-claimed', { run: runId });
112
+ if (job.item.ref !== o.item || job.check.key !== o.key) {
113
+ await board.release([runId]).catch(() => undefined);
114
+ claimed = false;
115
+ return stop(`The run ${runId} is for ${job.item.ref} ${job.check.key}, not ${o.item} ${o.key}, so it was given back.`, runId);
116
+ }
117
+ const thisBoard = board;
118
+ const thisRun = runId;
119
+ 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();
128
+ scope = new RunScope(runId, o.log);
129
+ scope.start();
130
+ 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
+ });
137
+ await ensureBrowser(o.log);
138
+ const preview = await startPreview(cfg, { runId, scope, log: o.log });
139
+ if (preview.note)
140
+ o.log(preview.note);
141
+ // -- SIGN IN AS THE RECIPE'S TEST USER -- //
142
+ let state = null;
143
+ if (preview.testUser) {
144
+ o.log(`Signing in as ${preview.testUser.email.replace(/^(.).*(@.*)$/, '$1***$2')}...`);
145
+ state = await signInWithPassword(preview.baseUrl, preview.testUser);
146
+ }
147
+ const needles = [
148
+ ...preview.secrets,
149
+ ...(preview.testUser ? [preview.testUser.password, preview.testUser.email] : []),
150
+ ...stateNeedles(state)
151
+ ];
152
+ const runDir = join(runsDir(), runId);
153
+ const browser = await launchBrowserServer({ headed: o.headed });
154
+ thisScope.add('browser', () => browser.close());
155
+ const childJob = {
156
+ baseUrl: preview.baseUrl,
157
+ ws: browser.wsEndpoint(),
158
+ storageState: state,
159
+ checkFile: script,
160
+ runDir,
161
+ viewport: { width: 1280, height: 800 },
162
+ extraHosts: [],
163
+ needles
164
+ };
165
+ const ran = await runCheckInSandbox({
166
+ job: childJob,
167
+ scriptsRoot: scriptsDir(),
168
+ log: o.log,
169
+ signal: abort.signal
170
+ });
171
+ 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;
179
+ return stop(`The check did not finish. ${why}`, runId);
180
+ }
181
+ // -- REPORT -- //
182
+ const ids = [];
183
+ for (const s of pickShots(ran.result.steps)) {
184
+ if (!s.shot)
185
+ continue;
186
+ const bytes = readFileSync(join(runDir, 'shots', s.shot));
187
+ // A picture that holds a secret (found as bytes or text) is not uploaded
188
+ if (needles.some((n) => n.length >= 6 && Buffer.from(bytes).includes(n))) {
189
+ o.log(' A picture held a secret value, so it was not uploaded.');
190
+ continue;
191
+ }
192
+ ids.push(await board.upload(runId, {
193
+ filename: s.shot,
194
+ caption: `${s.status === 'fail' ? 'Failed: ' : ''}${s.name}`.slice(0, 120),
195
+ bytes
196
+ }));
197
+ }
198
+ const result = ran.result;
199
+ const report = {
200
+ result: result.result,
201
+ durationMs: Math.min(result.durationMs, 24 * 3600 * 1000),
202
+ url: preview.baseUrl.slice(0, 300),
203
+ commit: preview.commit,
204
+ diffHash: preview.diffHash,
205
+ ...(result.result === 'pass'
206
+ ? {}
207
+ : { reason: redact(result.reason ?? 'The check failed.', needles).slice(0, 1900) }),
208
+ ...(preview.commit ? {} : { unverified: true }),
209
+ log: redact(result.log, needles).slice(0, 390_000),
210
+ evidence: ids
211
+ };
212
+ await board.finish(runId, report);
213
+ finished = true;
214
+ claimed = false;
215
+ appendAudit('run-finished', { run: runId, result: result.result });
216
+ return {
217
+ exitCode: result.result === 'pass' ? 0 : 1,
218
+ runId,
219
+ resultPath: join(runDir, 'result.json'),
220
+ message: result.result === 'pass'
221
+ ? 'The check passed.'
222
+ : `The check failed: ${result.reason ?? 'see the log'}`
223
+ };
224
+ }
225
+ catch (e) {
226
+ const reason = e instanceof Error ? e.message : String(e);
227
+ if (board && runId && claimed) {
228
+ await board
229
+ .finish(runId, { result: 'error', reason: reason.slice(0, 1900) })
230
+ .catch(() => undefined);
231
+ finished = true;
232
+ claimed = false;
233
+ }
234
+ return stop(reason, runId);
235
+ }
236
+ finally {
237
+ if (beat)
238
+ clearInterval(beat);
239
+ uninstall();
240
+ if (scope)
241
+ await scope.close();
242
+ // 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) {
244
+ const b = board ?? new RunnerBoard(o.server, o.runnerToken, o.fetch);
245
+ await b
246
+ .finish(o.claimed.id, {
247
+ result: 'error',
248
+ reason: (lastStop || 'The run stopped early.').slice(0, 1900)
249
+ })
250
+ .catch(() => undefined);
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,186 @@
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, readFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ // -- MODULES -- //
12
+ import { playwrightRuntimeDir } from './browser.js';
13
+ import { ensureDir, isInside, runsDir } from './paths.js';
14
+ import { redact } from './redact.js';
15
+ import { killTree } from './scope.js';
16
+ // -- CONSTANTS -- //
17
+ /** The hard limit of a whole check run. The child has its own 2 minute limit for the check itself, this is the backstop. */
18
+ export const RUN_TIMEOUT_MS = 5 * 60_000;
19
+ const SHOT = /^\d{2}\.jpg$/;
20
+ const MAX_STDERR = 4000;
21
+ const MAX_STDOUT = 2_000_000;
22
+ // -- FUNCTIONS -- //
23
+ /** The name of the permission flag of this Node: --permission since Node 23.5, --experimental-permission before. Null when it has neither. */
24
+ export function permissionFlag() {
25
+ const flags = process.allowedNodeEnvironmentFlags;
26
+ if (flags.has('--permission'))
27
+ return '--permission';
28
+ if (flags.has('--experimental-permission'))
29
+ return '--experimental-permission';
30
+ return null;
31
+ }
32
+ /** The environment of a sandboxed process: the least a Node program needs, a temp folder inside the run folder, and what the caller adds. */
33
+ export function sandboxEnv(tmpDir, extra = {}) {
34
+ const env = {};
35
+ if (process.env.PATH)
36
+ env.PATH = process.env.PATH;
37
+ if (process.env.SystemRoot)
38
+ env.SystemRoot = process.env.SystemRoot;
39
+ env.TEMP = tmpDir;
40
+ env.TMP = tmpDir;
41
+ return { ...env, ...extra };
42
+ }
43
+ /** Runs a Node script with the permission model on: it reads readDirs, writes writeDir, and starts nothing. */
44
+ export async function runNodeInSandbox(opts) {
45
+ const flag = permissionFlag();
46
+ if (!flag)
47
+ throw new Error(`This Node (${process.version}) has no permission model, so a check cannot be run safely. Use Node 22 or newer.`);
48
+ const args = [flag, ...opts.readDirs.map((d) => `--allow-fs-read=${d}`)];
49
+ if (opts.writeDir)
50
+ args.push(`--allow-fs-write=${opts.writeDir}`);
51
+ args.push(opts.entry);
52
+ const child = spawn(process.execPath, args, {
53
+ cwd: opts.writeDir ?? dirname(opts.entry),
54
+ env: opts.env,
55
+ windowsHide: true,
56
+ stdio: ['pipe', 'pipe', 'pipe'],
57
+ shell: false
58
+ });
59
+ let stdout = '';
60
+ let stderr = '';
61
+ let buf = '';
62
+ let timedOut = false;
63
+ const stop = () => {
64
+ if (child.pid !== undefined)
65
+ void Promise.resolve(killTree(child.pid)).catch(() => { });
66
+ child.kill();
67
+ };
68
+ const timer = setTimeout(() => {
69
+ timedOut = true;
70
+ stop();
71
+ }, opts.timeoutMs);
72
+ const onAbort = () => stop();
73
+ if (opts.signal) {
74
+ if (opts.signal.aborted)
75
+ stop();
76
+ else
77
+ opts.signal.addEventListener('abort', onAbort, { once: true });
78
+ }
79
+ child.stdout.on('data', (d) => {
80
+ const text = d.toString('utf8');
81
+ if (stdout.length < MAX_STDOUT)
82
+ stdout += text;
83
+ buf += text;
84
+ let i;
85
+ while ((i = buf.indexOf('\n')) >= 0) {
86
+ const line = buf.slice(0, i).replace(/\r$/, '');
87
+ buf = buf.slice(i + 1);
88
+ opts.onLine?.(line);
89
+ }
90
+ });
91
+ child.stderr.on('data', (d) => {
92
+ stderr = (stderr + d.toString('utf8')).slice(-MAX_STDERR);
93
+ });
94
+ // A child that dies before reading its stdin must not crash the parent
95
+ child.stdin.on('error', () => { });
96
+ const exitCode = await new Promise((resolve, reject) => {
97
+ child.on('error', reject);
98
+ child.on('close', (code) => resolve(code));
99
+ child.stdin.end(opts.stdin ?? '');
100
+ }).finally(() => {
101
+ clearTimeout(timer);
102
+ opts.signal?.removeEventListener('abort', onAbort);
103
+ });
104
+ return { exitCode, stdout, stderr, timedOut };
105
+ }
106
+ const redactDeep = (text, needles, max) => redact(String(text ?? ''), needles).slice(0, max);
107
+ /** 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
+ if (typeof raw !== 'object' || raw === null)
110
+ return null;
111
+ const r = raw;
112
+ const steps = Array.isArray(r.steps) ? r.steps : [];
113
+ return {
114
+ schema: 1,
115
+ result: r.result === 'pass' ? 'pass' : 'fail',
116
+ reason: typeof r.reason === 'string' && r.reason ? redactDeep(r.reason, needles, 700) : null,
117
+ durationMs: Number(r.durationMs) || 0,
118
+ steps: steps
119
+ .filter((s) => typeof s === 'object' && s !== null)
120
+ .slice(0, 200)
121
+ .map((s) => ({
122
+ name: redactDeep(s.name, needles, 200),
123
+ status: s.status === 'pass' || s.status === 'fail' ? s.status : 'shot',
124
+ ms: Number(s.ms) || 0,
125
+ shot: SHOT.test(String(s.shot ?? '')) ? String(s.shot) : null,
126
+ ...(s.error ? { error: redactDeep(s.error, needles, 500) } : {})
127
+ })),
128
+ log: redactDeep(r.log, needles, 20_000),
129
+ blockedWrites: Number(r.blockedWrites) || 0,
130
+ blockedOutside: Number(r.blockedOutside) || 0
131
+ };
132
+ }
133
+ /** Runs one check script in the sandbox and returns what it wrote. The browser is already running; the job names its address. */
134
+ export async function runCheckInSandbox({ job, scriptsRoot, log, signal }) {
135
+ if (!isInside(scriptsRoot, job.checkFile))
136
+ throw new Error('That check is not inside the scripts folder, so it was not run.');
137
+ if (!isInside(runsDir(), job.runDir))
138
+ throw new Error('The run folder is not inside the runs folder, so the check was not run.');
139
+ const pwDir = playwrightRuntimeDir();
140
+ if (!pwDir)
141
+ throw new Error('Playwright is not installed yet. Run "agent-flow-board check setup" and it will install it.');
142
+ const childFile = join(dirname(fileURLToPath(import.meta.url)), 'child.js');
143
+ if (!existsSync(childFile))
144
+ throw new Error('The check runner (child.js) is missing from this install. Build the bridge again.');
145
+ const needles = job.needles ?? [];
146
+ const tmpDir = ensureDir(join(job.runDir, 'tmp'));
147
+ const run = await runNodeInSandbox({
148
+ entry: childFile,
149
+ readDirs: [scriptsRoot, job.runDir, dirname(childFile), pwDir],
150
+ writeDir: job.runDir,
151
+ env: sandboxEnv(tmpDir, { AFB_PLAYWRIGHT_DIR: pwDir }),
152
+ stdin: JSON.stringify(job),
153
+ timeoutMs: RUN_TIMEOUT_MS,
154
+ signal,
155
+ onLine: (line) => {
156
+ if (!line.startsWith('EVENT '))
157
+ return;
158
+ try {
159
+ const e = JSON.parse(line.slice(6));
160
+ if (e.type === 'step') {
161
+ const secs = Math.round((Number(e.ms) || 0) / 100) / 10;
162
+ log(redact(` ${e.status === 'pass' ? 'PASS' : 'FAIL'} ${String(e.name ?? '')} (${secs}s)${e.error ? `: ${String(e.error)}` : ''}`, needles));
163
+ }
164
+ else if (e.type === 'log')
165
+ log(redact(` ${String(e.text ?? '')}`, needles));
166
+ }
167
+ catch {
168
+ /* Not an event */
169
+ }
170
+ }
171
+ });
172
+ const stderr = redact(run.stderr, needles);
173
+ const file = join(job.runDir, 'result.json');
174
+ let result = null;
175
+ if (existsSync(file)) {
176
+ try {
177
+ result = cleanResult(JSON.parse(readFileSync(file, 'utf8')), needles);
178
+ }
179
+ catch {
180
+ result = null;
181
+ }
182
+ }
183
+ if (run.timedOut)
184
+ log('The check ran for 5 minutes and was stopped.');
185
+ return { exitCode: run.exitCode, stderr, result };
186
+ }