@agent-flow-board/bridge 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/dist/bridge.js +256 -0
- package/dist/checks/audit.js +125 -0
- package/dist/checks/bind.js +99 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/browser.js +170 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +280 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +41 -0
- package/dist/checks/envPolicy.js +163 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/lock.js +183 -0
- package/dist/checks/paths.js +61 -0
- package/dist/checks/preview.js +350 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +232 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/run.js +253 -0
- package/dist/checks/sandbox.js +186 -0
- package/dist/checks/scope.js +294 -0
- package/dist/checks/signin.js +73 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/watch.js +101 -0
- package/dist/cli.js +329 -0
- package/dist/device.js +61 -0
- package/dist/hooks/adapters.js +218 -0
- package/dist/hooks/call.js +79 -0
- package/dist/hooks/gitHook.js +56 -0
- package/dist/hooks/modVersion.js +26 -0
- package/dist/hooks/settings.js +49 -0
- package/dist/hooks/setup.js +124 -0
- package/dist/hooks/statusFile.js +116 -0
- package/dist/mockup.js +145 -0
- package/dist/queue.js +175 -0
- package/dist/tokenStore.js +177 -0
- package/package.json +36 -5
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// THE BROWSER OF A CHECK: ONE CHROMIUM SERVER STARTED BY THE PARENT, WHICH THE SANDBOXED CHILD ONLY CONNECTS TO.
|
|
2
|
+
// Playwright is not a dependency of the bridge (it is large and most people never run a check). It is found next to the bridge when it is there,
|
|
3
|
+
// and otherwise installed once into ~/.agent-flow-board/checks/runtime. Nothing in this file imports Playwright at load time.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { execFile, spawn } from 'node:child_process';
|
|
6
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
// -- MODULES -- //
|
|
10
|
+
import { checksHome, ensureDir } from './paths.js';
|
|
11
|
+
// -- CONSTANTS -- //
|
|
12
|
+
/** The Playwright version the runtime install pins, so a check behaves the same on every machine. */
|
|
13
|
+
const PLAYWRIGHT_VERSION = '1.64.0';
|
|
14
|
+
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
15
|
+
// -- FUNCTIONS -- //
|
|
16
|
+
/** Where the runtime install lives. It is a folder of the bridge's own state, never a repo. */
|
|
17
|
+
const runtimeDir = () => join(checksHome(), 'runtime');
|
|
18
|
+
const looksInstalled = (nodeModules) => existsSync(join(nodeModules, 'playwright-core', 'package.json'));
|
|
19
|
+
/**
|
|
20
|
+
* The node_modules folder that holds playwright-core (and @playwright/test when it is there): next to the bridge first, then the runtime
|
|
21
|
+
* install. The sandboxed child is allowed to read this folder and nothing else outside its own. Null when Playwright is not installed anywhere.
|
|
22
|
+
*/
|
|
23
|
+
export function playwrightRuntimeDir() {
|
|
24
|
+
try {
|
|
25
|
+
const pkg = createRequire(import.meta.url).resolve('playwright-core/package.json');
|
|
26
|
+
// .../node_modules/playwright-core/package.json -> .../node_modules
|
|
27
|
+
return dirname(dirname(pkg));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* Not next to the bridge: try the runtime install */
|
|
31
|
+
}
|
|
32
|
+
const own = join(runtimeDir(), 'node_modules');
|
|
33
|
+
return looksInstalled(own) ? own : null;
|
|
34
|
+
}
|
|
35
|
+
/** Loads playwright-core from the folder playwrightRuntimeDir names, by absolute path. */
|
|
36
|
+
export async function loadPlaywright() {
|
|
37
|
+
const dir = playwrightRuntimeDir();
|
|
38
|
+
if (!dir)
|
|
39
|
+
throw new Error('Playwright is not installed yet. Run "agent-flow-board check setup" and it will install it.');
|
|
40
|
+
const req = createRequire(join(dir, 'package.json'));
|
|
41
|
+
return req(join(dir, 'playwright-core'));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A browser the parent starts, so a check's process only connects to it and never has to start a program itself.
|
|
45
|
+
* Every host except the preview's (and the hosts named) is unresolvable, whatever a check does with routes.
|
|
46
|
+
*/
|
|
47
|
+
export async function launchBrowserServer({ headed = false, hosts = [] } = {}) {
|
|
48
|
+
const { chromium } = await loadPlaywright();
|
|
49
|
+
const exclude = [...new Set(['127.0.0.1', 'localhost', ...hosts])]
|
|
50
|
+
.map((h) => `EXCLUDE ${h}`)
|
|
51
|
+
.join(', ');
|
|
52
|
+
const server = await chromium.launchServer({
|
|
53
|
+
headless: !headed,
|
|
54
|
+
args: [
|
|
55
|
+
`--host-resolver-rules=MAP * ~NOTFOUND, ${exclude}`,
|
|
56
|
+
'--disable-background-networking',
|
|
57
|
+
'--no-first-run'
|
|
58
|
+
]
|
|
59
|
+
});
|
|
60
|
+
return { wsEndpoint: () => server.wsEndpoint(), close: () => server.close() };
|
|
61
|
+
}
|
|
62
|
+
/** Says whether an installer failure looks like no network or a proxy, so the one-line error can tell the person what to look at. */
|
|
63
|
+
export function describeInstallFailure(output) {
|
|
64
|
+
if (/proxy|407|tunnel|ECONNRESET|self[- ]signed|certificate/i.test(output))
|
|
65
|
+
return 'it looks like a proxy problem (check HTTPS_PROXY and the proxy settings)';
|
|
66
|
+
if (/ENOTFOUND|EAI_AGAIN|ENETUNREACH|ECONNREFUSED|ETIMEDOUT|getaddrinfo|offline|network/i.test(output))
|
|
67
|
+
return 'it looks like this machine is offline';
|
|
68
|
+
return 'see the last lines above';
|
|
69
|
+
}
|
|
70
|
+
/** Runs a program with the exact arguments (no shell, no stdin) and sends every output line to `log`. Resolves with the exit code and the last output. */
|
|
71
|
+
function runLogged(file, args, log, cwd) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const child = spawn(file, args, {
|
|
74
|
+
cwd,
|
|
75
|
+
shell: false,
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
78
|
+
});
|
|
79
|
+
let tail = '';
|
|
80
|
+
const feed = (chunk) => {
|
|
81
|
+
for (const line of chunk.toString('utf8').split(/\r?\n/)) {
|
|
82
|
+
const t = line.trim();
|
|
83
|
+
if (!t)
|
|
84
|
+
continue;
|
|
85
|
+
tail = `${tail}\n${t}`.slice(-2000);
|
|
86
|
+
log(t);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
child.stdout.on('data', feed);
|
|
90
|
+
child.stderr.on('data', feed);
|
|
91
|
+
const timer = setTimeout(() => child.kill(), INSTALL_TIMEOUT_MS);
|
|
92
|
+
child.on('error', (e) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
reject(e);
|
|
95
|
+
});
|
|
96
|
+
child.on('close', (code) => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve({ code, tail });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** The npm that belongs to this Node: its npm-cli.js, so no shell is needed. Null when it is not where Node keeps it. */
|
|
103
|
+
function npmCli() {
|
|
104
|
+
const root = dirname(process.execPath);
|
|
105
|
+
const candidates = [
|
|
106
|
+
join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
107
|
+
join(root, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
108
|
+
];
|
|
109
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Installs Playwright into the runtime folder of the bridge's state.
|
|
113
|
+
* This is the ONE place npm is used: it installs a runtime for the installed bridge into ~/.agent-flow-board, never into a repo,
|
|
114
|
+
* so the Yarn-only rule of the repos does not apply here.
|
|
115
|
+
*/
|
|
116
|
+
async function installRuntime(log) {
|
|
117
|
+
const dir = ensureDir(runtimeDir());
|
|
118
|
+
const manifest = join(dir, 'package.json');
|
|
119
|
+
if (!existsSync(manifest))
|
|
120
|
+
writeFileSync(manifest, JSON.stringify({ private: true }, null, 2));
|
|
121
|
+
log(`Installing Playwright ${PLAYWRIGHT_VERSION} into ${dir} (first use only)...`);
|
|
122
|
+
// @playwright/test brings the expect that checks use; playwright-core is what the parent drives
|
|
123
|
+
const npmArgs = [
|
|
124
|
+
'install',
|
|
125
|
+
'--no-audit',
|
|
126
|
+
'--no-fund',
|
|
127
|
+
'--prefix',
|
|
128
|
+
dir,
|
|
129
|
+
`playwright-core@${PLAYWRIGHT_VERSION}`,
|
|
130
|
+
`@playwright/test@${PLAYWRIGHT_VERSION}`
|
|
131
|
+
];
|
|
132
|
+
const cli = npmCli();
|
|
133
|
+
const out = await new Promise((resolve) => {
|
|
134
|
+
const done = (error, stdout, stderr) => resolve({ error, text: `${stdout}\n${stderr}` });
|
|
135
|
+
if (cli)
|
|
136
|
+
execFile(process.execPath, [cli, ...npmArgs], { windowsHide: true, timeout: INSTALL_TIMEOUT_MS, shell: false }, (e, so, se) => done(e, so, se));
|
|
137
|
+
else
|
|
138
|
+
execFile('npm', npmArgs, { windowsHide: true, timeout: INSTALL_TIMEOUT_MS, shell: process.platform === 'win32' }, (e, so, se) => done(e, so, se));
|
|
139
|
+
});
|
|
140
|
+
if (out.error || !looksInstalled(join(dir, 'node_modules'))) {
|
|
141
|
+
const last = out.text.trim().split('\n').slice(-3).join(' ').slice(0, 300);
|
|
142
|
+
throw new Error(`Could not install Playwright: ${describeInstallFailure(out.text)}. ${last}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Makes sure Playwright and its Chromium are there. The first use installs them (a few hundred megabytes, with progress lines in `log`);
|
|
147
|
+
* later uses return at once. Never prompts. A failure is one Error that says whether it looks like offline or a proxy.
|
|
148
|
+
*/
|
|
149
|
+
export async function ensureBrowser(log = () => { }) {
|
|
150
|
+
if (!playwrightRuntimeDir())
|
|
151
|
+
await installRuntime(log);
|
|
152
|
+
const dir = playwrightRuntimeDir();
|
|
153
|
+
if (!dir)
|
|
154
|
+
throw new Error('Playwright was installed but cannot be found.');
|
|
155
|
+
const { chromium } = await loadPlaywright();
|
|
156
|
+
try {
|
|
157
|
+
if (existsSync(chromium.executablePath()))
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* No executable path known yet: install below */
|
|
162
|
+
}
|
|
163
|
+
log('Installing the Chromium browser for Playwright (first use only)...');
|
|
164
|
+
const cli = join(dir, 'playwright-core', 'cli.js');
|
|
165
|
+
if (!existsSync(cli))
|
|
166
|
+
throw new Error('Playwright is installed but its installer (cli.js) is missing. Remove the runtime folder and try again.');
|
|
167
|
+
const { code, tail } = await runLogged(process.execPath, [cli, 'install', 'chromium'], log).catch((e) => ({ code: -1, tail: String(e) }));
|
|
168
|
+
if (code !== 0)
|
|
169
|
+
throw new Error(`Could not install the Chromium browser: ${describeInstallFailure(tail)}. ${tail.split('\n').slice(-2).join(' ').trim().slice(0, 300)}`);
|
|
170
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// RUNS ONE CHECK FILE IN ITS OWN PROCESS, started by sandbox.ts with Node's permission model on (dist/checks/child.js).
|
|
2
|
+
// It reads one job from stdin (the saved sign-in is handed over there, never written to disk here), drives the page, writes its
|
|
3
|
+
// result and its pictures into the run folder, and prints one line per event. It never sees the board or any token.
|
|
4
|
+
// Under the permission model there are no child processes, no worker threads and no file access outside the folders sandbox.ts names,
|
|
5
|
+
// and the only environment variable it reads is AFB_PLAYWRIGHT_DIR. It imports nothing that touches the bridge's token store.
|
|
6
|
+
// -- DEP-MODULES -- //
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
// -- MODULES -- //
|
|
12
|
+
import { guardContext, pageForCheck, unwrap } from './guard.js';
|
|
13
|
+
import { redact } from './redact.js';
|
|
14
|
+
// -- CONSTANTS -- //
|
|
15
|
+
const CHECK_LIMIT_MS = 120_000;
|
|
16
|
+
const MAX_SHOT_HEIGHT = 2400;
|
|
17
|
+
// -- FUNCTIONS -- //
|
|
18
|
+
const readStdin = () => new Promise((resolve) => {
|
|
19
|
+
let s = '';
|
|
20
|
+
process.stdin.setEncoding('utf8');
|
|
21
|
+
process.stdin.on('data', (d) => (s += d));
|
|
22
|
+
process.stdin.on('end', () => resolve(s));
|
|
23
|
+
});
|
|
24
|
+
const messageOf = (e) => (e instanceof Error ? e.message : String(e));
|
|
25
|
+
async function main() {
|
|
26
|
+
const job = JSON.parse(await readStdin());
|
|
27
|
+
const needles = job.needles ?? [];
|
|
28
|
+
const extraHosts = job.extraHosts ?? [];
|
|
29
|
+
const origin = new URL(job.baseUrl).origin;
|
|
30
|
+
// PLAYWRIGHT: the parent names the node_modules folder that holds it; it is the only place outside the run folder this process can read
|
|
31
|
+
const pwDir = process.env.AFB_PLAYWRIGHT_DIR;
|
|
32
|
+
if (!pwDir)
|
|
33
|
+
throw new Error('The parent did not say where Playwright is.');
|
|
34
|
+
const req = createRequire(import.meta.url);
|
|
35
|
+
const { chromium } = req(join(pwDir, 'playwright-core'));
|
|
36
|
+
// expect() comes with @playwright/test. Without it a check that calls expect gets one plain sentence
|
|
37
|
+
let realExpect;
|
|
38
|
+
try {
|
|
39
|
+
realExpect = req(join(pwDir, '@playwright', 'test')).expect;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
realExpect = () => {
|
|
43
|
+
throw new Error('expect() needs @playwright/test, which is not installed next to Playwright. Run "agent-flow-board check setup".');
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// expect() needs the real locator, so it is given one here, and the check only ever sees the matchers
|
|
47
|
+
const checkExpect = Object.assign((value, message) => realExpect(unwrap(value), message), {
|
|
48
|
+
soft: (value, message) => realExpect.soft?.(unwrap(value), message),
|
|
49
|
+
poll: (fn, options) => realExpect.poll?.(fn, options)
|
|
50
|
+
});
|
|
51
|
+
const out = (event) => process.stdout.write(`EVENT ${JSON.stringify(event)}\n`);
|
|
52
|
+
const say = (m) => out({ type: 'log', text: redact(String(m), needles).slice(0, 600) });
|
|
53
|
+
const shotsDir = join(job.runDir, 'shots');
|
|
54
|
+
mkdirSync(shotsDir, { recursive: true });
|
|
55
|
+
const steps = [];
|
|
56
|
+
const lines = [];
|
|
57
|
+
let n = 0;
|
|
58
|
+
let page;
|
|
59
|
+
let expiredSeen = false;
|
|
60
|
+
let counts = { writes: 0, outside: 0 };
|
|
61
|
+
async function takeShot(caption) {
|
|
62
|
+
if (!page)
|
|
63
|
+
return null;
|
|
64
|
+
const file = `${String(++n).padStart(2, '0')}.jpg`;
|
|
65
|
+
try {
|
|
66
|
+
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
67
|
+
const bytes = await page.screenshot({
|
|
68
|
+
type: 'jpeg',
|
|
69
|
+
quality: 55,
|
|
70
|
+
fullPage: true,
|
|
71
|
+
...(height > MAX_SHOT_HEIGHT
|
|
72
|
+
? { clip: { x: 0, y: 0, width: job.viewport.width, height: MAX_SHOT_HEIGHT } }
|
|
73
|
+
: {})
|
|
74
|
+
});
|
|
75
|
+
writeFileSync(join(shotsDir, file), bytes);
|
|
76
|
+
return { file, caption };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let result = 'pass';
|
|
83
|
+
let reason;
|
|
84
|
+
const started = Date.now();
|
|
85
|
+
// The browser was started by the tool; this process only connects to it, so it never has to start a program itself
|
|
86
|
+
const browser = await chromium.connect(job.ws);
|
|
87
|
+
try {
|
|
88
|
+
const ctx = await browser.newContext({
|
|
89
|
+
baseURL: job.baseUrl,
|
|
90
|
+
viewport: job.viewport,
|
|
91
|
+
storageState: job.storageState ?? undefined,
|
|
92
|
+
serviceWorkers: 'block'
|
|
93
|
+
});
|
|
94
|
+
// ChildJob has no allowWrites: a check run in the sandbox never writes
|
|
95
|
+
counts = await guardContext(ctx, { origin, allowWrites: false, extraHosts, onBlocked: say });
|
|
96
|
+
const real = await ctx.newPage();
|
|
97
|
+
page = real;
|
|
98
|
+
let violation = null;
|
|
99
|
+
// A page that ends up anywhere but the app under test (file:, chrome:, view-source:) fails the run
|
|
100
|
+
real.on('framenavigated', (f) => {
|
|
101
|
+
try {
|
|
102
|
+
const u = new URL(f.url());
|
|
103
|
+
if (u.protocol !== 'about:' &&
|
|
104
|
+
(!['http:', 'https:'].includes(u.protocol) ||
|
|
105
|
+
(u.origin !== origin && !extraHosts.includes(u.hostname))))
|
|
106
|
+
violation = `The page went to ${u.protocol}//${u.host}, which is not the app under test.`;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* about:blank and the like */
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
// A signed-in run that is sent to a sign-in page has an expired sign-in
|
|
113
|
+
real.on('framenavigated', (f) => {
|
|
114
|
+
try {
|
|
115
|
+
if (f === real.mainFrame() &&
|
|
116
|
+
job.storageState &&
|
|
117
|
+
/\/(sign-?in|log-?in|login|auth)(\/|$|\?)/i.test(new URL(f.url()).pathname))
|
|
118
|
+
expiredSeen = true;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* about:blank and the like */
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const step = async (name, fn) => {
|
|
125
|
+
const t0 = Date.now();
|
|
126
|
+
try {
|
|
127
|
+
await fn();
|
|
128
|
+
const shot = await takeShot(name);
|
|
129
|
+
steps.push({ name, status: 'pass', ms: Date.now() - t0, shot: shot?.file ?? null });
|
|
130
|
+
out({ type: 'step', name, status: 'pass', ms: Date.now() - t0 });
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
const shot = await takeShot(`${name} (failed)`);
|
|
134
|
+
const error = redact(messageOf(e).split('\n').slice(0, 4).join(' '), needles).slice(0, 500);
|
|
135
|
+
steps.push({ name, status: 'fail', ms: Date.now() - t0, shot: shot?.file ?? null, error });
|
|
136
|
+
out({ type: 'step', name, status: 'fail', ms: Date.now() - t0, error });
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const shot = async (caption = 'Shot') => {
|
|
141
|
+
const s = await takeShot(caption);
|
|
142
|
+
if (s)
|
|
143
|
+
steps.push({ name: caption, status: 'shot', ms: 0, shot: s.file });
|
|
144
|
+
};
|
|
145
|
+
const log = (m) => {
|
|
146
|
+
const t = redact(String(m), needles);
|
|
147
|
+
lines.push(t);
|
|
148
|
+
say(t);
|
|
149
|
+
};
|
|
150
|
+
const fn = (await import(pathToFileURL(job.checkFile).href)).default;
|
|
151
|
+
if (typeof fn !== 'function')
|
|
152
|
+
throw new Error('A check file must export default an async function({ page, step, shot, expect, log, baseUrl }).');
|
|
153
|
+
let timer;
|
|
154
|
+
try {
|
|
155
|
+
await Promise.race([
|
|
156
|
+
fn({
|
|
157
|
+
page: pageForCheck(real, { origin, extraHosts }),
|
|
158
|
+
step,
|
|
159
|
+
shot,
|
|
160
|
+
expect: checkExpect,
|
|
161
|
+
log,
|
|
162
|
+
baseUrl: job.baseUrl
|
|
163
|
+
}),
|
|
164
|
+
new Promise((_, rej) => (timer = setTimeout(() => rej(new Error('The check took longer than 2 minutes.')), CHECK_LIMIT_MS)))
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
}
|
|
170
|
+
if (violation)
|
|
171
|
+
throw new Error(violation);
|
|
172
|
+
if (!steps.length) {
|
|
173
|
+
const s = await takeShot('End of the check');
|
|
174
|
+
if (s)
|
|
175
|
+
steps.push({ name: 'End of the check', status: 'shot', ms: 0, shot: s.file });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
result = 'fail';
|
|
180
|
+
reason = redact(messageOf(e).split('\n').slice(0, 4).join(' '), needles).slice(0, 700);
|
|
181
|
+
if (!steps.some((s) => s.status === 'fail') && page) {
|
|
182
|
+
const s = await takeShot('Where it stopped');
|
|
183
|
+
if (s)
|
|
184
|
+
steps.push({ name: 'Where it stopped', status: 'shot', ms: 0, shot: s.file });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await browser.close().catch(() => { });
|
|
189
|
+
}
|
|
190
|
+
if (result === 'fail' && expiredSeen)
|
|
191
|
+
reason =
|
|
192
|
+
`The saved sign-in no longer works: the app sent the browser to its sign-in page. Sign in again and rerun. (${reason ?? ''})`.trim();
|
|
193
|
+
const final = {
|
|
194
|
+
schema: 1,
|
|
195
|
+
result,
|
|
196
|
+
reason: reason ?? null,
|
|
197
|
+
durationMs: Date.now() - started,
|
|
198
|
+
steps,
|
|
199
|
+
log: lines.join('\n'),
|
|
200
|
+
blockedWrites: counts.writes,
|
|
201
|
+
blockedOutside: counts.outside
|
|
202
|
+
};
|
|
203
|
+
writeFileSync(join(job.runDir, 'result.json'), JSON.stringify(final, null, 1));
|
|
204
|
+
out({ type: 'done', result });
|
|
205
|
+
}
|
|
206
|
+
main().then(
|
|
207
|
+
// Nothing else is waiting, so end now instead of waiting for stray timers
|
|
208
|
+
() => process.exit(0), (e) => {
|
|
209
|
+
process.stderr.write(`${messageOf(e).slice(0, 600)}\n`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// THE `check` COMMAND GROUP: connect, run, pull, bind, watch, status, doctor, clear, setup
|
|
2
|
+
// Loaded only when the first argument is `check`, so the hooks and the MCP forwarder never load any of it (and never load Playwright).
|
|
3
|
+
// Nothing here waits on a terminal: every command takes its input from flags and ends on its own.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, resolve } from 'node:path';
|
|
8
|
+
// -- MODULES -- //
|
|
9
|
+
import { verifyAudit } from './audit.js';
|
|
10
|
+
import { bindFolder, listBindings, pinStatus } from './bind.js';
|
|
11
|
+
import { ensureBrowser, playwrightRuntimeDir } from './browser.js';
|
|
12
|
+
import { connectChecks, loadRunnerToken, runnerAccount } from './connect.js';
|
|
13
|
+
import { forgetConnection, listConnections, recordConnection } from './connections.js';
|
|
14
|
+
import { ORG_SLUG, checksHome, scriptPath } from './paths.js';
|
|
15
|
+
import { pinFiles, resolveProjectConfig } from './recipe.js';
|
|
16
|
+
import { runCheck } from './run.js';
|
|
17
|
+
import { clearRuns, sweepOrphans } from './scope.js';
|
|
18
|
+
import { permissionFlag } from './sandbox.js';
|
|
19
|
+
import { watchChecks } from './watch.js';
|
|
20
|
+
// -- CONSTANTS -- //
|
|
21
|
+
export const CHECK_USAGE = [
|
|
22
|
+
'Usage: agent-flow-board check <command>',
|
|
23
|
+
' connect --server <url> --org <slug> [--project KEY,KEY] [--json] make this machine a runner (an agent approves the code)',
|
|
24
|
+
' run <item> <key> --server <url> --org <slug> [--step 1.2] [--run <id>] [--dir <repo>] [--json] run one check in this repo',
|
|
25
|
+
' pull <item> <key> --server <url> --org <slug> [--accept-hash <sha256>] read the board copy of a script; accept it by hash',
|
|
26
|
+
' bind --org <slug> --project <KEY> [--dir <repo>] remember the folder of a board project for "check watch"',
|
|
27
|
+
' watch [--parallel N] [--once] take queued runs from the boards this machine is connected to',
|
|
28
|
+
' status [--json] | doctor [--fix] [--json] | clear [--all] | setup | disconnect --server <url> --org <slug>'
|
|
29
|
+
].join('\n');
|
|
30
|
+
// -- FUNCTIONS -- //
|
|
31
|
+
/** Runs one check command and returns the exit code: 0 done, 1 a check failed or a problem was listed, 2 could not run. */
|
|
32
|
+
export async function checkMain(ctx) {
|
|
33
|
+
const { args, log, out } = ctx;
|
|
34
|
+
const sub = args[0];
|
|
35
|
+
const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
|
|
36
|
+
const has = (name) => args.includes(name);
|
|
37
|
+
const json = has('--json');
|
|
38
|
+
const need = (name) => {
|
|
39
|
+
const v = flag(name);
|
|
40
|
+
if (!v || v.startsWith('--'))
|
|
41
|
+
throw new Error(`Say ${name} <value>.`);
|
|
42
|
+
return v;
|
|
43
|
+
};
|
|
44
|
+
const server = () => {
|
|
45
|
+
const s = flag('--server') ?? process.env.AFB_SERVER;
|
|
46
|
+
if (!s)
|
|
47
|
+
throw new Error('Say which board to use: --server https://board.example.com (or set AFB_SERVER).');
|
|
48
|
+
return s.replace(/\/$/, '');
|
|
49
|
+
};
|
|
50
|
+
const org = () => {
|
|
51
|
+
const o = need('--org');
|
|
52
|
+
if (!ORG_SLUG.test(o))
|
|
53
|
+
throw new Error('--org takes an organisation slug: lowercase letters, digits and dashes.');
|
|
54
|
+
return o;
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
if (sub === 'connect') {
|
|
58
|
+
const s = server();
|
|
59
|
+
const o = org();
|
|
60
|
+
const projects = flag('--project')
|
|
61
|
+
?.split(',')
|
|
62
|
+
.map((p) => p.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
const done = await connectChecks({
|
|
65
|
+
server: s,
|
|
66
|
+
org: o,
|
|
67
|
+
projects,
|
|
68
|
+
json,
|
|
69
|
+
store: ctx.store,
|
|
70
|
+
fetch: ctx.fetch,
|
|
71
|
+
log,
|
|
72
|
+
out
|
|
73
|
+
});
|
|
74
|
+
recordConnection({ server: s, org: o, name: done.name });
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
if (sub === 'disconnect') {
|
|
78
|
+
const s = server();
|
|
79
|
+
const o = org();
|
|
80
|
+
ctx.store.delete(runnerAccount(s, o));
|
|
81
|
+
forgetConnection(s, o);
|
|
82
|
+
log('Disconnected. The runner on the board is removed from the board settings.');
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
if (sub === 'run') {
|
|
86
|
+
const item = args[1];
|
|
87
|
+
const key = args[2];
|
|
88
|
+
if (!item || !key || item.startsWith('--') || key.startsWith('--'))
|
|
89
|
+
throw new Error('Usage: agent-flow-board check run <item> <key> --server <url> --org <slug>');
|
|
90
|
+
const s = server();
|
|
91
|
+
const o = org();
|
|
92
|
+
const token = loadRunnerToken(ctx.store, s, o);
|
|
93
|
+
if (!token)
|
|
94
|
+
throw new Error(`This machine is not connected to ${o} on ${s}. Run: agent-flow-board check connect --server ${s} --org ${o} --json`);
|
|
95
|
+
const outcome = await runCheck({
|
|
96
|
+
server: s,
|
|
97
|
+
org: o,
|
|
98
|
+
item,
|
|
99
|
+
key,
|
|
100
|
+
dir: resolve(flag('--dir') ?? process.cwd()),
|
|
101
|
+
step: flag('--step'),
|
|
102
|
+
runId: flag('--run'),
|
|
103
|
+
runnerToken: token,
|
|
104
|
+
personalToken: ctx.personalToken(s, o),
|
|
105
|
+
fetch: ctx.fetch,
|
|
106
|
+
log,
|
|
107
|
+
headed: has('--headed')
|
|
108
|
+
});
|
|
109
|
+
if (json)
|
|
110
|
+
out(JSON.stringify({
|
|
111
|
+
ok: outcome.exitCode === 0,
|
|
112
|
+
runId: outcome.runId,
|
|
113
|
+
message: outcome.message
|
|
114
|
+
}));
|
|
115
|
+
else
|
|
116
|
+
log(outcome.message);
|
|
117
|
+
// The last line is always the result file, so an agent can read it
|
|
118
|
+
if (outcome.resultPath)
|
|
119
|
+
out(`RESULT ${outcome.resultPath}`);
|
|
120
|
+
return outcome.exitCode;
|
|
121
|
+
}
|
|
122
|
+
if (sub === 'pull') {
|
|
123
|
+
const item = args[1];
|
|
124
|
+
const key = args[2];
|
|
125
|
+
if (!item || !key)
|
|
126
|
+
throw new Error('Usage: agent-flow-board check pull <item> <key> --server <url> --org <slug> [--accept-hash <sha256>]');
|
|
127
|
+
const s = server();
|
|
128
|
+
const o = org();
|
|
129
|
+
const personal = ctx.personalToken(s, o);
|
|
130
|
+
if (!personal)
|
|
131
|
+
throw new Error(`Not signed in to ${o} on ${s}. Run: agent-flow-board login --json --server ${s} --org ${o}`);
|
|
132
|
+
const res = await ctx.fetch(`${s}/api/v1/orgs/${encodeURIComponent(o)}/items/${encodeURIComponent(item)}/browser-checks`, {
|
|
133
|
+
headers: { authorization: `Bearer ${personal}` },
|
|
134
|
+
signal: AbortSignal.timeout(30_000)
|
|
135
|
+
});
|
|
136
|
+
const body = (await res.json().catch(() => ({})));
|
|
137
|
+
if (!res.ok)
|
|
138
|
+
throw new Error(body.error?.message ?? `The board answered ${res.status}.`);
|
|
139
|
+
const script = body.checks?.find((c) => c.key === key)?.script;
|
|
140
|
+
if (!script)
|
|
141
|
+
throw new Error(`The board has no saved script for ${item} ${key}.`);
|
|
142
|
+
const hash = createHash('sha256').update(script).digest('hex');
|
|
143
|
+
const target = scriptPath(o, item, key);
|
|
144
|
+
const local = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
145
|
+
const accept = flag('--accept-hash');
|
|
146
|
+
if (accept) {
|
|
147
|
+
if (accept !== hash)
|
|
148
|
+
throw new Error('The board script changed since you read it (its hash is different), so nothing was written. Pull again.');
|
|
149
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
150
|
+
writeFileSync(target, script);
|
|
151
|
+
log(`Wrote ${target}.`);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
// The script is text written by someone else: it is shown as data and never run until it is accepted by its hash
|
|
155
|
+
out(JSON.stringify({
|
|
156
|
+
item,
|
|
157
|
+
key,
|
|
158
|
+
hash,
|
|
159
|
+
local: local === null ? 'none' : local === script ? 'same' : 'different',
|
|
160
|
+
target,
|
|
161
|
+
script
|
|
162
|
+
}));
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
if (sub === 'bind') {
|
|
166
|
+
const o = org();
|
|
167
|
+
const project = need('--project');
|
|
168
|
+
const dir = resolve(flag('--dir') ?? process.cwd());
|
|
169
|
+
const cfg = resolveProjectConfig(dir);
|
|
170
|
+
bindFolder(o, project, cfg.repo, pinFiles(cfg.repo));
|
|
171
|
+
log(`Bound ${o}/${project} to ${cfg.repo}.`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
if (sub === 'watch') {
|
|
175
|
+
const ac = new AbortController();
|
|
176
|
+
const stop = () => ac.abort();
|
|
177
|
+
process.once('SIGINT', stop);
|
|
178
|
+
process.once('SIGTERM', stop);
|
|
179
|
+
const n = await watchChecks({
|
|
180
|
+
store: ctx.store,
|
|
181
|
+
fetch: ctx.fetch,
|
|
182
|
+
log,
|
|
183
|
+
parallel: Number(flag('--parallel') ?? 3) || 3,
|
|
184
|
+
once: has('--once'),
|
|
185
|
+
signal: ac.signal
|
|
186
|
+
});
|
|
187
|
+
log(`Took ${n} run(s).`);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
if (sub === 'setup') {
|
|
191
|
+
await ensureBrowser(log);
|
|
192
|
+
log('The browser is ready.');
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
if (sub === 'status' || sub === 'doctor')
|
|
196
|
+
return await statusOrDoctor(ctx, sub === 'doctor', has('--fix'), json);
|
|
197
|
+
if (sub === 'clear') {
|
|
198
|
+
const r = clearRuns({ all: has('--all') });
|
|
199
|
+
log(`Removed ${r.removed} finished run folder(s).`);
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
log(CHECK_USAGE);
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
207
|
+
if (json)
|
|
208
|
+
out(JSON.stringify({ ok: false, event: 'error', error: message }));
|
|
209
|
+
else
|
|
210
|
+
log(message);
|
|
211
|
+
return 2;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function statusOrDoctor(ctx, doctor, fix, json) {
|
|
215
|
+
const problems = [];
|
|
216
|
+
const audit = verifyAudit();
|
|
217
|
+
if (!audit.ok)
|
|
218
|
+
problems.push({
|
|
219
|
+
problem: 'The audit log does not verify (it was edited or cut).',
|
|
220
|
+
next: 'Do not edit it. Move checks/audit.log aside to start a new chain.'
|
|
221
|
+
});
|
|
222
|
+
if (!permissionFlag())
|
|
223
|
+
problems.push({
|
|
224
|
+
problem: 'This Node has no permission model, so check scripts cannot be sandboxed.',
|
|
225
|
+
next: 'Install Node 20.18 or later (or any Node 22).'
|
|
226
|
+
});
|
|
227
|
+
if (!playwrightRuntimeDir()) {
|
|
228
|
+
if (doctor && fix)
|
|
229
|
+
await ensureBrowser(ctx.log);
|
|
230
|
+
else
|
|
231
|
+
problems.push({
|
|
232
|
+
problem: 'The browser is not installed.',
|
|
233
|
+
next: 'Run: agent-flow-board check setup'
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const swept = doctor && fix ? await sweepOrphans(ctx.log) : 0;
|
|
237
|
+
const connections = listConnections().map((c) => ({
|
|
238
|
+
...c,
|
|
239
|
+
connected: Boolean(loadRunnerToken(ctx.store, c.server, c.org))
|
|
240
|
+
}));
|
|
241
|
+
for (const c of connections)
|
|
242
|
+
if (!c.connected)
|
|
243
|
+
problems.push({
|
|
244
|
+
problem: `${c.org} on ${c.server} has no runner token.`,
|
|
245
|
+
next: `Run: agent-flow-board check connect --server ${c.server} --org ${c.org} --json`
|
|
246
|
+
});
|
|
247
|
+
const bindings = listBindings().map((b) => ({
|
|
248
|
+
org: b.org,
|
|
249
|
+
project: b.projectKey,
|
|
250
|
+
folder: b.folder,
|
|
251
|
+
pin: pinStatus(b.org, b.projectKey)
|
|
252
|
+
}));
|
|
253
|
+
for (const b of bindings)
|
|
254
|
+
if (b.pin !== 'ok')
|
|
255
|
+
problems.push({
|
|
256
|
+
problem: `${b.org}/${b.project}: the files that decide what runs changed in ${b.folder}.`,
|
|
257
|
+
next: `In that folder run a check once: agent-flow-board check run <item> <key> --org ${b.org}`
|
|
258
|
+
});
|
|
259
|
+
const state = {
|
|
260
|
+
home: checksHome(),
|
|
261
|
+
connections,
|
|
262
|
+
bindings,
|
|
263
|
+
audit: { ok: audit.ok, lines: audit.lines },
|
|
264
|
+
browser: Boolean(playwrightRuntimeDir()),
|
|
265
|
+
swept,
|
|
266
|
+
problems
|
|
267
|
+
};
|
|
268
|
+
if (json)
|
|
269
|
+
ctx.out(JSON.stringify(state));
|
|
270
|
+
else {
|
|
271
|
+
ctx.log(`State folder: ${state.home}`);
|
|
272
|
+
ctx.log(`Connections: ${connections.map((c) => `${c.org}@${c.server}${c.connected ? '' : ' (no token)'}`).join(', ') || 'none'}`);
|
|
273
|
+
ctx.log(`Bound folders: ${bindings.map((b) => `${b.org}/${b.project} -> ${b.folder} (${b.pin})`).join('; ') || 'none'}`);
|
|
274
|
+
for (const p of problems)
|
|
275
|
+
ctx.log(`Problem: ${p.problem} Next: ${p.next}`);
|
|
276
|
+
if (!problems.length)
|
|
277
|
+
ctx.log('Nothing is wrong.');
|
|
278
|
+
}
|
|
279
|
+
return doctor && problems.length ? 1 : 0;
|
|
280
|
+
}
|