@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.
- package/README.md +13 -1
- 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 +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,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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// CONNECTING THIS MACHINE TO A BOARD AS A CHECK RUNNER
|
|
2
|
+
// One device sign in under the client name afb-checks (an agent approves it with tool_connection_approve and the verifier, or a person types the
|
|
3
|
+
// code on the /device page), one call that makes this machine a runner of the organisation, then only the runner token is kept. The personal
|
|
4
|
+
// token is forgotten at once. The runner token has its own entry in the token store, apart from the sign in of the hooks and MCP.
|
|
5
|
+
// -- DEP-MODULES -- //
|
|
6
|
+
import { hostname } from 'node:os';
|
|
7
|
+
// -- MODULES -- //
|
|
8
|
+
import { deviceLogin } from '../device.js';
|
|
9
|
+
import { accountKey } from '../tokenStore.js';
|
|
10
|
+
// -- CONSTANTS -- //
|
|
11
|
+
/** The client name the board approves with the manage permission, used once to make the runner. */
|
|
12
|
+
export const TOOL_CLIENT_NAME = 'afb-checks';
|
|
13
|
+
// -- FUNCTIONS -- //
|
|
14
|
+
/** The token store entry of the runner token for a server and organisation. */
|
|
15
|
+
export const runnerAccount = (server, org) => `${accountKey(server, org)}.checks`;
|
|
16
|
+
/**
|
|
17
|
+
* A name for this machine's runner. A repeat of the same owner and name replaces the old token on the board, so this name is different from the
|
|
18
|
+
* one the standalone afb-checks tool uses on the same machine: connecting the bridge must never cut that tool off.
|
|
19
|
+
*/
|
|
20
|
+
export function runnerName() {
|
|
21
|
+
const host = hostname()
|
|
22
|
+
.replace(/[^A-Za-z0-9 ._-]/g, '')
|
|
23
|
+
.slice(0, 36) || 'this-machine';
|
|
24
|
+
return `${host} agent-flow-board`.slice(0, 60);
|
|
25
|
+
}
|
|
26
|
+
/** The runner token for a server and organisation, or null when this machine has not been connected. AFB_CHECKS_TOKEN overrides it, for tests. */
|
|
27
|
+
export function loadRunnerToken(store, server, org) {
|
|
28
|
+
return process.env.AFB_CHECKS_TOKEN ?? store.get(runnerAccount(server, org));
|
|
29
|
+
}
|
|
30
|
+
/** Makes this machine a runner of the organisation. Never prints a token. */
|
|
31
|
+
export async function connectChecks(options) {
|
|
32
|
+
const url = new URL(options.server);
|
|
33
|
+
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))
|
|
34
|
+
throw new Error('The board address must start with https:// (only an address on this machine may use http).');
|
|
35
|
+
const personal = await deviceLogin(options.server, options.fetch, options.log, {
|
|
36
|
+
name: TOOL_CLIENT_NAME,
|
|
37
|
+
pkce: true,
|
|
38
|
+
wait: options.wait,
|
|
39
|
+
onCode: (c) => {
|
|
40
|
+
if (options.json)
|
|
41
|
+
options.out(JSON.stringify({
|
|
42
|
+
event: 'code',
|
|
43
|
+
userCode: c.userCode,
|
|
44
|
+
verificationUri: c.verificationUri,
|
|
45
|
+
verifier: c.verifier,
|
|
46
|
+
expiresIn: c.expiresInSeconds
|
|
47
|
+
}));
|
|
48
|
+
else
|
|
49
|
+
options.log('On that page choose your organisation and tick "Change project settings" (manage). This is used once, to make this machine a runner.');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const name = runnerName();
|
|
53
|
+
const projects = options.projects?.length ? options.projects : undefined;
|
|
54
|
+
const res = await options.fetch(`${options.server}/api/v1/orgs/${encodeURIComponent(options.org)}/runners/tool`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
authorization: `Bearer ${personal.token}`,
|
|
58
|
+
'content-type': 'application/json'
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({ name, ...(projects ? { projects } : {}) }),
|
|
61
|
+
signal: AbortSignal.timeout(30_000)
|
|
62
|
+
});
|
|
63
|
+
const body = (await res.json().catch(() => ({})));
|
|
64
|
+
if (!res.ok || !body.token)
|
|
65
|
+
throw new Error(body.error?.message ?? `The board answered ${res.status} when making this machine a runner.`);
|
|
66
|
+
options.store.set(runnerAccount(options.server, options.org), body.token);
|
|
67
|
+
const served = projects ? projects.join(', ') : 'every project of the organisation you may use';
|
|
68
|
+
if (options.json)
|
|
69
|
+
options.out(JSON.stringify({
|
|
70
|
+
event: 'connected',
|
|
71
|
+
server: options.server,
|
|
72
|
+
org: options.org,
|
|
73
|
+
name,
|
|
74
|
+
projects: projects ?? 'all'
|
|
75
|
+
}));
|
|
76
|
+
else
|
|
77
|
+
options.log(`Connected as "${name}". It serves ${served}.`);
|
|
78
|
+
return { name, served };
|
|
79
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// THE BOARDS THIS MACHINE IS CONNECTED TO FOR CHECKS: SERVER AND ORGANISATION ONLY, NEVER A TOKEN (THE TOKEN IS IN THE TOKEN STORE)
|
|
2
|
+
// `check watch` re-reads this list on every loop, so a `check connect` for a second organisation is picked up without a restart.
|
|
3
|
+
// -- DEP-MODULES -- //
|
|
4
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
// -- MODULES -- //
|
|
7
|
+
import { checksHome, ensureDir, ORG_SLUG } from './paths.js';
|
|
8
|
+
// -- FUNCTIONS -- //
|
|
9
|
+
const file = () => join(checksHome(), 'connections.json');
|
|
10
|
+
export function listConnections() {
|
|
11
|
+
if (!existsSync(file()))
|
|
12
|
+
return [];
|
|
13
|
+
try {
|
|
14
|
+
const v = JSON.parse(readFileSync(file(), 'utf8'));
|
|
15
|
+
return Array.isArray(v)
|
|
16
|
+
? v.filter((c) => !!c &&
|
|
17
|
+
typeof c.server === 'string' &&
|
|
18
|
+
typeof c.org === 'string' &&
|
|
19
|
+
ORG_SLUG.test(c.org) &&
|
|
20
|
+
typeof c.name === 'string')
|
|
21
|
+
: [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Adds or replaces the connection for a server and organisation. Written whole, never half. */
|
|
28
|
+
export function recordConnection(c) {
|
|
29
|
+
const next = [...listConnections().filter((x) => !(x.server === c.server && x.org === c.org)), c];
|
|
30
|
+
ensureDir(checksHome());
|
|
31
|
+
const tmp = `${file()}.${process.pid}.tmp`;
|
|
32
|
+
writeFileSync(tmp, JSON.stringify(next, null, 1), { mode: 0o600 });
|
|
33
|
+
renameSync(tmp, file());
|
|
34
|
+
}
|
|
35
|
+
export function forgetConnection(server, org) {
|
|
36
|
+
const next = listConnections().filter((x) => !(x.server === server && x.org === org));
|
|
37
|
+
ensureDir(checksHome());
|
|
38
|
+
const tmp = `${file()}.${process.pid}.tmp`;
|
|
39
|
+
writeFileSync(tmp, JSON.stringify(next, null, 1), { mode: 0o600 });
|
|
40
|
+
renameSync(tmp, file());
|
|
41
|
+
}
|