@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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @agent-flow-board/bridge
|
|
2
2
|
|
|
3
|
-
The local bridge to an [Agent Flow Board](https://afb.arbenger.com) server. It forwards the board's MCP over stdio, runs the hooks of your coding tool (Claude Code, Antigravity, Gemini CLI) so the board stays true, and writes the status file that the editor status line reads. It holds no tool code: every tool lives on the server.
|
|
3
|
+
The local bridge to an [Agent Flow Board](https://afb.arbenger.com) server. It forwards the board's MCP over stdio, runs the hooks of your coding tool (Claude Code, Antigravity, Gemini CLI) so the board stays true, and writes the status file that the editor status line reads. It holds no board tool code: every MCP tool lives on the server. It also runs browser checks on this machine (see Browser checks below).
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -22,6 +22,18 @@ Needs Node 20 or later. Run it with no arguments to see its usage; the list shou
|
|
|
22
22
|
| `agent-flow-board mcp --server <url> --org <slug>` | Forwards MCP over stdio, for tools that only speak stdio. |
|
|
23
23
|
| `agent-flow-board queue list` | Shows writes held while the board could not be reached. |
|
|
24
24
|
|
|
25
|
+
### Browser checks
|
|
26
|
+
|
|
27
|
+
| Command | What it does |
|
|
28
|
+
| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `agent-flow-board check connect --server <url> --org <slug> --json` | Makes this machine a check runner of the organisation. An agent approves the printed code with the board's MCP. Only the runner token is kept. |
|
|
30
|
+
| `agent-flow-board check run <item> <key> --server <url> --org <slug> [--step 1.2]` | Asks for the run, claims that run only, builds a private copy of the repo in the current folder, previews it, runs the check in a sandbox and reports. Exit 0 pass, 1 fail, 2 could not run. |
|
|
31
|
+
| `agent-flow-board check pull <item> <key> --server <url> --org <slug>` | Shows the board's copy of a check script as data. `--accept-hash <sha256>` writes exactly that text. |
|
|
32
|
+
| `agent-flow-board check watch [--parallel N] [--once]` | Takes runs started from the board's Run button, for folders where `check run` was used before. |
|
|
33
|
+
| `agent-flow-board check status`, `check doctor --fix`, `check clear`, `check setup` | Reads and repairs the state, removes old runs, installs the browser. All take `--json`. |
|
|
34
|
+
|
|
35
|
+
Check scripts and run state live in `~/.agent-flow-board/checks/`, never in a repo. Playwright is not installed with the bridge: the first run installs it there.
|
|
36
|
+
|
|
25
37
|
The easy way to set everything up is the **Editor status** tab on the board's **Connect a tool** page: copy its prompt into Claude Code and the agent does the steps, asking you once before it writes anything.
|
|
26
38
|
|
|
27
39
|
## What it writes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A HASH-CHAINED AUDIT LOG OF WHAT THE CHECK COMMANDS DID. Each line carries the MAC of the line before it (HMAC-SHA256 with a key that only this machine has),
|
|
2
|
+
// so an edited line or a cut shows. It is evidence, not prevention. Only short action words and plain numbers are logged: never a token, a password, a secret's
|
|
3
|
+
// path or a command text.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { appendFileSync, existsSync, linkSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
// -- MODULES -- //
|
|
9
|
+
import { withLockSync } from './lock.js';
|
|
10
|
+
import { auditFile, checksHome, ensureDir } from './paths.js';
|
|
11
|
+
// -- CONSTANTS -- //
|
|
12
|
+
const MAX_ACTION = 40;
|
|
13
|
+
const MAX_VALUE = 200;
|
|
14
|
+
// -- FUNCTIONS -- //
|
|
15
|
+
const keyFile = () => join(checksHome(), 'audit.key');
|
|
16
|
+
/** The head file keeps the MAC and the count of the last line, which is what shows a cut tail (the chain of the lines left is still true). */
|
|
17
|
+
const headFile = () => `${auditFile()}.head`;
|
|
18
|
+
/** The key, created once with mode 0600. It is written whole into a temporary file and linked into place, so another process never reads half of it. */
|
|
19
|
+
function homeKey() {
|
|
20
|
+
ensureDir(checksHome());
|
|
21
|
+
const file = keyFile();
|
|
22
|
+
if (!existsSync(file)) {
|
|
23
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
24
|
+
writeFileSync(tmp, randomBytes(32).toString('hex'), { mode: 0o600, flag: 'wx' });
|
|
25
|
+
try {
|
|
26
|
+
linkSync(tmp, file);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e.code !== 'EEXIST')
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
rmSync(tmp, { force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return Buffer.from(readFileSync(file, 'utf8').trim(), 'hex');
|
|
37
|
+
}
|
|
38
|
+
const lineMac = (key, prev, text) => createHmac('sha256', Buffer.concat([Buffer.from('afb check audit:'), key]))
|
|
39
|
+
.update(`${prev}\n${text}`)
|
|
40
|
+
.digest('hex');
|
|
41
|
+
const readLines = () => existsSync(auditFile()) ? readFileSync(auditFile(), 'utf8').split(/\r?\n/).filter(Boolean) : [];
|
|
42
|
+
function tail(lines) {
|
|
43
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
44
|
+
try {
|
|
45
|
+
const e = JSON.parse(lines[i] ?? '');
|
|
46
|
+
if (e && typeof e === 'object' && typeof e.mac === 'string')
|
|
47
|
+
return e.mac;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* A damaged line is skipped, and verifyAudit reports it */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
function clean(detail) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [k, v] of Object.entries(detail)) {
|
|
58
|
+
const name = k.replace(/[^A-Za-z0-9_]/g, '').slice(0, 40);
|
|
59
|
+
if (name)
|
|
60
|
+
out[name] = typeof v === 'string' ? v.slice(0, MAX_VALUE) : v;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Adds one line to the chain. Reading the last line and appending the next one is one step under a lock, so two writers never fork the chain. */
|
|
65
|
+
export function appendAudit(action, detail = {}) {
|
|
66
|
+
const key = homeKey();
|
|
67
|
+
const word = action.replace(/[^A-Za-z0-9 _.:-]/g, '').slice(0, MAX_ACTION) || 'action';
|
|
68
|
+
withLockSync('audit', () => {
|
|
69
|
+
const lines = readLines();
|
|
70
|
+
const prev = tail(lines);
|
|
71
|
+
const text = JSON.stringify({
|
|
72
|
+
at: new Date().toISOString(),
|
|
73
|
+
action: word,
|
|
74
|
+
detail: clean(detail),
|
|
75
|
+
prev
|
|
76
|
+
});
|
|
77
|
+
const mac = lineMac(key, prev, text);
|
|
78
|
+
appendFileSync(auditFile(), JSON.stringify({ ...JSON.parse(text), mac }) + '\n', {
|
|
79
|
+
mode: 0o600
|
|
80
|
+
});
|
|
81
|
+
const head = { lines: lines.length + 1, head: mac };
|
|
82
|
+
const tmp = `${headFile()}.${process.pid}.tmp`;
|
|
83
|
+
writeFileSync(tmp, JSON.stringify(head), { mode: 0o600 });
|
|
84
|
+
renameSync(tmp, headFile());
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function readHead() {
|
|
88
|
+
try {
|
|
89
|
+
const v = JSON.parse(readFileSync(headFile(), 'utf8'));
|
|
90
|
+
return typeof v.lines === 'number' && typeof v.head === 'string'
|
|
91
|
+
? { lines: v.lines, head: v.head }
|
|
92
|
+
: null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const same = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
99
|
+
/** True when every line holds the MAC of the line before it and the last line is the one the head file names (a cut tail shows there). */
|
|
100
|
+
export function verifyAudit() {
|
|
101
|
+
const lines = readLines();
|
|
102
|
+
const head = readHead();
|
|
103
|
+
if (!lines.length)
|
|
104
|
+
return { ok: head === null || head.lines === 0, lines: 0, head: '' };
|
|
105
|
+
const key = homeKey();
|
|
106
|
+
let prev = '';
|
|
107
|
+
for (let i = 0; i < lines.length; i++) {
|
|
108
|
+
let e;
|
|
109
|
+
try {
|
|
110
|
+
e = JSON.parse(lines[i] ?? '');
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { ok: false, lines: lines.length, head: prev };
|
|
114
|
+
}
|
|
115
|
+
const { mac, ...rest } = e;
|
|
116
|
+
if (typeof mac !== 'string' || e.prev !== prev)
|
|
117
|
+
return { ok: false, lines: lines.length, head: prev };
|
|
118
|
+
if (!same(lineMac(key, prev, JSON.stringify(rest)), mac))
|
|
119
|
+
return { ok: false, lines: lines.length, head: prev };
|
|
120
|
+
prev = mac;
|
|
121
|
+
}
|
|
122
|
+
// A cut tail, or a line added by hand after the last one, is not what the head file recorded
|
|
123
|
+
const matches = head !== null && head.lines === lines.length && head.head === prev;
|
|
124
|
+
return { ok: matches, lines: lines.length, head: prev };
|
|
125
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// THE FOLDER BINDING OF `check watch`: WHICH REAL FOLDER BUILDS THE PROJECT <org>/<KEY>, AND THE PIN OF THE FILES THAT DECIDED WHAT RUNS.
|
|
2
|
+
// When the pin changes (a new script, a changed recipe, a new lockfile) the watcher stops and tells the person to run `check run` in that
|
|
3
|
+
// folder, which looks at the new setup and pins it again. Nothing in the repo can re-pin by itself.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { existsSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
// -- MODULES -- //
|
|
8
|
+
import { bindingsFile, ensureDir, ORG_SLUG } from './paths.js';
|
|
9
|
+
import { checkRepoFolder } from './guardrails.js';
|
|
10
|
+
import { pinFiles } from './recipe.js';
|
|
11
|
+
// -- CONSTANTS -- //
|
|
12
|
+
const PROJECT_KEY = /^[A-Z][A-Z0-9]{1,9}$/;
|
|
13
|
+
// -- FUNCTIONS -- //
|
|
14
|
+
const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
15
|
+
function idOf(org, projectKey) {
|
|
16
|
+
if (!ORG_SLUG.test(org))
|
|
17
|
+
throw new Error('The organisation is not a slug.');
|
|
18
|
+
if (!PROJECT_KEY.test(projectKey))
|
|
19
|
+
throw new Error('The project key must be 2 to 10 capital letters or digits, starting with a letter.');
|
|
20
|
+
return `${org}/${projectKey}`;
|
|
21
|
+
}
|
|
22
|
+
function readAll() {
|
|
23
|
+
const file = bindingsFile();
|
|
24
|
+
if (!existsSync(file))
|
|
25
|
+
return {};
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`${file} is not valid JSON. Fix or remove it.`);
|
|
32
|
+
}
|
|
33
|
+
const out = {};
|
|
34
|
+
if (isRecord(parsed) && isRecord(parsed.bindings))
|
|
35
|
+
for (const [id, v] of Object.entries(parsed.bindings))
|
|
36
|
+
if (isRecord(v) && typeof v.folder === 'string' && typeof v.pin === 'string')
|
|
37
|
+
out[id] = { folder: v.folder, pin: v.pin };
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Writes a temp file in the same folder, then renames it over the real one, so a reader never sees half a file. */
|
|
41
|
+
function writeAll(bindings) {
|
|
42
|
+
const file = bindingsFile();
|
|
43
|
+
ensureDir(dirname(file));
|
|
44
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(temp, JSON.stringify({ version: 1, bindings }, null, 2) + '\n', { mode: 0o600 });
|
|
47
|
+
renameSync(temp, file);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
rmSync(temp, { force: true });
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Binds a project to a folder. Refuses a folder the guardrails refuse. Stores the real path and the given pin. */
|
|
55
|
+
export function bindFolder(org, projectKey, folder, pin) {
|
|
56
|
+
const id = idOf(org, projectKey);
|
|
57
|
+
const problem = checkRepoFolder(folder);
|
|
58
|
+
if (problem)
|
|
59
|
+
throw new Error(problem);
|
|
60
|
+
const all = readAll();
|
|
61
|
+
all[id] = { folder: realpathSync.native(folder), pin };
|
|
62
|
+
writeAll(all);
|
|
63
|
+
}
|
|
64
|
+
/** The folder and pin of a project, or null when it has none. */
|
|
65
|
+
export function boundFolder(org, projectKey) {
|
|
66
|
+
const found = readAll()[idOf(org, projectKey)];
|
|
67
|
+
return found ? { folder: found.folder, pin: found.pin } : null;
|
|
68
|
+
}
|
|
69
|
+
/** Every binding, sorted by org and key. */
|
|
70
|
+
export function listBindings() {
|
|
71
|
+
return Object.entries(readAll())
|
|
72
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
73
|
+
.map(([id, v]) => {
|
|
74
|
+
const [org = '', projectKey = ''] = id.split('/');
|
|
75
|
+
return { org, projectKey, folder: v.folder, pin: v.pin };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Removes a binding. True when there was one. */
|
|
79
|
+
export function unbind(org, projectKey) {
|
|
80
|
+
const id = idOf(org, projectKey);
|
|
81
|
+
const all = readAll();
|
|
82
|
+
if (!(id in all))
|
|
83
|
+
return false;
|
|
84
|
+
delete all[id];
|
|
85
|
+
writeAll(all);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether the files that decide what runs are still the ones the person approved. `changed` also covers a folder that is gone or no longer
|
|
90
|
+
* allowed: the caller tells the person to run `check run` in that folder, which looks again and re-pins.
|
|
91
|
+
*/
|
|
92
|
+
export function pinStatus(org, projectKey) {
|
|
93
|
+
const found = boundFolder(org, projectKey);
|
|
94
|
+
if (!found)
|
|
95
|
+
return 'unbound';
|
|
96
|
+
if (checkRepoFolder(found.folder))
|
|
97
|
+
return 'changed';
|
|
98
|
+
return pinFiles(join(found.folder)) === found.pin ? 'ok' : 'changed';
|
|
99
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// THE BOARD, SEEN FROM THE CHECK COMMANDS: THE RUNNER API WITH THE TOOL'S OWN RUNNER TOKEN
|
|
2
|
+
// A runner token is not a personal token. It can take a queued run, beat for it, upload evidence and report a result, and nothing else.
|
|
3
|
+
// -- MODULES -- //
|
|
4
|
+
import { TOOL_PROTOCOL } from './paths.js';
|
|
5
|
+
// -- CONSTANTS -- //
|
|
6
|
+
/** One call to the board may take this long, uploads included. A half open connection must not hang the run for ever. */
|
|
7
|
+
export const CALL_TIMEOUT_MS = 120_000;
|
|
8
|
+
/** Asking for a run waits a short while only. */
|
|
9
|
+
export const CLAIM_TIMEOUT_MS = 20_000;
|
|
10
|
+
/** Raised when the board refuses a call. Carries the board's own words and code. */
|
|
11
|
+
export class BoardError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
code;
|
|
14
|
+
constructor(status, code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = 'BoardError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// -- FUNCTIONS -- //
|
|
22
|
+
/** The calls of the runner API, as the check commands make them. */
|
|
23
|
+
export class RunnerBoard {
|
|
24
|
+
server;
|
|
25
|
+
token;
|
|
26
|
+
doFetch;
|
|
27
|
+
extraHeaders;
|
|
28
|
+
constructor(server, token, doFetch = (input, init) => fetch(input, init),
|
|
29
|
+
/** Extra headers sent with every call: the audit head and the slots, built by the caller, never required. */
|
|
30
|
+
extraHeaders = () => ({})) {
|
|
31
|
+
this.server = server;
|
|
32
|
+
this.token = token;
|
|
33
|
+
this.doFetch = doFetch;
|
|
34
|
+
this.extraHeaders = extraHeaders;
|
|
35
|
+
}
|
|
36
|
+
async call(method, path, body, type, timeoutMs = CALL_TIMEOUT_MS) {
|
|
37
|
+
let res;
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
res = await this.doFetch(`${this.server.replace(/\/$/, '')}/api/v1/runner${path}`, {
|
|
41
|
+
method,
|
|
42
|
+
headers: {
|
|
43
|
+
authorization: `Bearer ${this.token}`,
|
|
44
|
+
'x-afb-tool-protocol': String(TOOL_PROTOCOL),
|
|
45
|
+
...this.extraHeaders(),
|
|
46
|
+
...(type ? { 'content-type': type } : {})
|
|
47
|
+
},
|
|
48
|
+
body: body,
|
|
49
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
50
|
+
});
|
|
51
|
+
text = await res.text();
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
const name = e?.name;
|
|
55
|
+
if (name === 'TimeoutError' || name === 'AbortError')
|
|
56
|
+
throw new BoardError(0, 'TIMEOUT', `The board did not answer within ${Math.round(timeoutMs / 1000)} seconds.`);
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
let json = null;
|
|
60
|
+
try {
|
|
61
|
+
json = text ? JSON.parse(text) : null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* Not JSON */
|
|
65
|
+
}
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const err = json?.error;
|
|
68
|
+
if (res.status === 401)
|
|
69
|
+
throw new BoardError(401, 'UNAUTHENTICATED', 'The board no longer accepts this machine for checks. Run: agent-flow-board check connect --server <url> --org <slug> --json');
|
|
70
|
+
throw new BoardError(res.status, err?.code ?? 'ERROR', err?.message ?? `The board answered ${res.status}.`);
|
|
71
|
+
}
|
|
72
|
+
return json;
|
|
73
|
+
}
|
|
74
|
+
/** Takes the run with this id, and no other. Null when it is not queued for this runner. */
|
|
75
|
+
async claim(runId) {
|
|
76
|
+
const out = await this.call('POST', '/claim', JSON.stringify({ runId }), 'application/json', CLAIM_TIMEOUT_MS);
|
|
77
|
+
return out.job;
|
|
78
|
+
}
|
|
79
|
+
/** Takes the oldest queued run this runner may take, for `check watch`. */
|
|
80
|
+
async claimNext() {
|
|
81
|
+
const out = await this.call('POST', '/claim', undefined, undefined, CLAIM_TIMEOUT_MS);
|
|
82
|
+
return out.job;
|
|
83
|
+
}
|
|
84
|
+
/** How many runs wait for this runner. Nothing is taken. */
|
|
85
|
+
async peek() {
|
|
86
|
+
return (await this.call('POST', '/peek')).waiting;
|
|
87
|
+
}
|
|
88
|
+
/** Says the process is alive and still on this run. A conflict means the board gave up on it. */
|
|
89
|
+
beat(runId) {
|
|
90
|
+
return this.call('POST', `/runs/${encodeURIComponent(runId)}/beat`);
|
|
91
|
+
}
|
|
92
|
+
/** Gives back the named runs only, never the runs of another process of the same runner. */
|
|
93
|
+
async release(runIds) {
|
|
94
|
+
if (!runIds.length)
|
|
95
|
+
return 0;
|
|
96
|
+
return (await this.call('POST', '/release', JSON.stringify({ runIds }), 'application/json')).released;
|
|
97
|
+
}
|
|
98
|
+
/** Stores a file of a run as evidence and returns its attachment id. */
|
|
99
|
+
async upload(runId, file) {
|
|
100
|
+
const q = `filename=${encodeURIComponent(file.filename)}&caption=${encodeURIComponent(file.caption)}`;
|
|
101
|
+
const out = await this.call('POST', `/runs/${encodeURIComponent(runId)}/artifacts?${q}`, file.bytes, 'application/octet-stream');
|
|
102
|
+
return out.attachmentId;
|
|
103
|
+
}
|
|
104
|
+
finish(runId, report) {
|
|
105
|
+
return this.call('POST', `/runs/${encodeURIComponent(runId)}/finish`, JSON.stringify(report), 'application/json');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -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
|
+
}
|