@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arben Apura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @agent-flow-board/bridge
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install -g @agent-flow-board/bridge
|
|
9
|
+
agent-flow-board
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Needs Node 20 or later. Run it with no arguments to see its usage; the list should include `setup`. Install it globally: the hooks call the command several times per tool call, and a command started through `npx` each time is too slow.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
| Command | What it does |
|
|
17
|
+
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
|
+
| `agent-flow-board login --server <url> --org <slug>` | Signs in with a device code. The sign in is kept in your keychain, one entry per server and organisation. |
|
|
19
|
+
| `agent-flow-board login --json --server <url> --org <slug>` | For an agent: line 1 holds the code and a verifier, so the agent can approve it through the board's MCP. It never prints a token. |
|
|
20
|
+
| `agent-flow-board setup --org <slug> --server <url>` | Writes the Claude Code hooks into this repository's `.claude/settings.local.json` (`--shared` writes `settings.json`). It merges, keeps your other hooks and backs the old file up outside the repository. |
|
|
21
|
+
| `agent-flow-board hooks print claude --server <url> --org <slug>` | Prints the hook settings to paste by hand. |
|
|
22
|
+
| `agent-flow-board mcp --server <url> --org <slug>` | Forwards MCP over stdio, for tools that only speak stdio. |
|
|
23
|
+
| `agent-flow-board queue list` | Shows writes held while the board could not be reached. |
|
|
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
|
+
|
|
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.
|
|
38
|
+
|
|
39
|
+
## What it writes
|
|
40
|
+
|
|
41
|
+
- Your sign in: in the operating system keychain (an encrypted file where there is none).
|
|
42
|
+
- `~/.agent-flow-board/status/<conversation id>.json`: what the board says a conversation works on. It holds no token and is removed when the conversation ends.
|
|
43
|
+
- `~/.agent-flow-board/backups/`: backups of settings files that `setup` changed.
|
|
44
|
+
|
|
45
|
+
## Licence
|
|
46
|
+
|
|
47
|
+
MIT. See `LICENSE`.
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// THE BRIDGE CORE (ADR-030): READS JSON-RPC LINES FROM A TOOL'S STDIN, FORWARDS EACH TO THE SERVER'S /mcp, WRITES THE REPLIES TO STDOUT.
|
|
2
|
+
// It holds no tool code, schemas or workflow text, so a server update never needs a reconnect. It only adds three things:
|
|
3
|
+
// sign in by device code, an offline queue for writes, and a protocol version check on start.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
// -- CONSTANTS -- //
|
|
7
|
+
export const BRIDGE_PROTOCOL_MAX = 1;
|
|
8
|
+
/** The longest one request may take. A tool call can wait up to 50 seconds for a run, so this sits above that. */
|
|
9
|
+
export const POST_TIMEOUT_MS = 90_000;
|
|
10
|
+
const NETWORK_CODES = new Set([
|
|
11
|
+
'ECONNREFUSED',
|
|
12
|
+
'ECONNRESET',
|
|
13
|
+
'ENOTFOUND',
|
|
14
|
+
'EAI_AGAIN',
|
|
15
|
+
'ETIMEDOUT',
|
|
16
|
+
'ENETUNREACH',
|
|
17
|
+
'EHOSTUNREACH',
|
|
18
|
+
'EPIPE',
|
|
19
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
20
|
+
'UND_ERR_SOCKET'
|
|
21
|
+
]);
|
|
22
|
+
// -- FUNCTIONS -- //
|
|
23
|
+
/** A request that timed out: the board may or may not have taken it, so it is never treated as offline. */
|
|
24
|
+
const isTimeout = (e) => e?.name === 'TimeoutError' ||
|
|
25
|
+
e?.name === 'AbortError';
|
|
26
|
+
/** Only a real failure to reach the board counts. Any other TypeError is a bug and must be shown, not hidden as offline. */
|
|
27
|
+
export const isNetworkError = (e) => {
|
|
28
|
+
if (!e || typeof e !== 'object')
|
|
29
|
+
return false;
|
|
30
|
+
const err = e;
|
|
31
|
+
if (err.code && NETWORK_CODES.has(err.code))
|
|
32
|
+
return true;
|
|
33
|
+
if (err.cause?.code && NETWORK_CODES.has(err.cause.code))
|
|
34
|
+
return true;
|
|
35
|
+
// Node's fetch reports a failed connection as a TypeError whose message is exactly this
|
|
36
|
+
return (e instanceof TypeError &&
|
|
37
|
+
/^(fetch failed|failed to fetch|network request failed)/i.test(String(err.message)));
|
|
38
|
+
};
|
|
39
|
+
/** The board refused the saved sign in. The person has to sign in again, which the bridge starts in the background. */
|
|
40
|
+
class SignInNeeded extends Error {
|
|
41
|
+
}
|
|
42
|
+
/** Runs until stdin closes. */
|
|
43
|
+
export async function runBridge(o) {
|
|
44
|
+
let token = o.getToken();
|
|
45
|
+
const postTimeout = o.postTimeoutMs ?? POST_TIMEOUT_MS;
|
|
46
|
+
let loginRunning = null;
|
|
47
|
+
/** Starts the device code sign in once. It runs in the background, so no call waits for the person to type a code. */
|
|
48
|
+
const startLogin = () => {
|
|
49
|
+
if (loginRunning)
|
|
50
|
+
return;
|
|
51
|
+
loginRunning = o
|
|
52
|
+
.login()
|
|
53
|
+
.then((t) => {
|
|
54
|
+
token = t;
|
|
55
|
+
})
|
|
56
|
+
.catch((err) => o.log(`Sign in failed: ${String(err?.message ?? err)}`))
|
|
57
|
+
.finally(() => {
|
|
58
|
+
loginRunning = null;
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
if (!token) {
|
|
62
|
+
o.log('No saved sign in. Starting the sign in.');
|
|
63
|
+
startLogin();
|
|
64
|
+
}
|
|
65
|
+
const protocolMax = o.protocolMax ?? BRIDGE_PROTOCOL_MAX;
|
|
66
|
+
let warnedProtocol = false;
|
|
67
|
+
/** Tool names known to only read, from the server's own annotations. Anything else is treated as a write. */
|
|
68
|
+
let readOnly = new Set();
|
|
69
|
+
let toolsKnown = false;
|
|
70
|
+
const write = (msg) => o.stdout.write(JSON.stringify(msg) + '\n');
|
|
71
|
+
const post = async (body) => {
|
|
72
|
+
const send = () => o.fetch(`${o.server}/mcp${o.org ? `/${o.org}` : ''}`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'content-type': 'application/json',
|
|
76
|
+
accept: 'application/json, text/event-stream',
|
|
77
|
+
authorization: `Bearer ${token}`
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
signal: AbortSignal.timeout(postTimeout)
|
|
81
|
+
});
|
|
82
|
+
if (!token)
|
|
83
|
+
throw new SignInNeeded();
|
|
84
|
+
const res = await send();
|
|
85
|
+
if (res.status === 401) {
|
|
86
|
+
// The token expired or was revoked. The sign in starts in the background and this call says so, instead of waiting for it
|
|
87
|
+
o.log('The saved sign in is no longer valid. Starting the sign in.');
|
|
88
|
+
token = null;
|
|
89
|
+
startLogin();
|
|
90
|
+
throw new SignInNeeded();
|
|
91
|
+
}
|
|
92
|
+
return res;
|
|
93
|
+
};
|
|
94
|
+
const parse = async (res) => {
|
|
95
|
+
const text = await res.text();
|
|
96
|
+
if (!text.trim())
|
|
97
|
+
return null;
|
|
98
|
+
if ((res.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
|
99
|
+
const line = text.split('\n').find((l) => l.startsWith('data:'));
|
|
100
|
+
return line ? JSON.parse(line.slice(5)) : null;
|
|
101
|
+
}
|
|
102
|
+
return JSON.parse(text);
|
|
103
|
+
};
|
|
104
|
+
const checkProtocol = (res) => {
|
|
105
|
+
const v = Number(res.headers.get('x-afb-protocol') ?? 0);
|
|
106
|
+
if (v > protocolMax && !warnedProtocol) {
|
|
107
|
+
warnedProtocol = true;
|
|
108
|
+
o.log(`This server speaks protocol ${v} and the bridge understands up to ${protocolMax}. Update it with: npx -y @agent-flow-board/bridge@latest`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/** Sends one queued write as a real tools/call and says whether the board took it. */
|
|
112
|
+
const sendQueued = async (e) => {
|
|
113
|
+
try {
|
|
114
|
+
const res = await post({
|
|
115
|
+
jsonrpc: '2.0',
|
|
116
|
+
id: `replay-${Date.now()}`,
|
|
117
|
+
method: 'tools/call',
|
|
118
|
+
params: { name: e.name, arguments: e.args }
|
|
119
|
+
});
|
|
120
|
+
const body = await parse(res);
|
|
121
|
+
const result = body?.result;
|
|
122
|
+
if (res.status >= 500)
|
|
123
|
+
return { kind: 'offline' };
|
|
124
|
+
if (!res.ok || body?.error || result?.isError)
|
|
125
|
+
return {
|
|
126
|
+
kind: 'refused',
|
|
127
|
+
reason: result?.structuredContent?.error?.message ?? `The board answered ${res.status}.`
|
|
128
|
+
};
|
|
129
|
+
return { kind: 'ok' };
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (err instanceof SignInNeeded || isTimeout(err) || isNetworkError(err))
|
|
133
|
+
return { kind: 'offline' };
|
|
134
|
+
return { kind: 'refused', reason: String(err.message) };
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const replay = async () => {
|
|
138
|
+
if (o.queue.list().length === 0)
|
|
139
|
+
return;
|
|
140
|
+
const r = await o.queue.replay(sendQueued);
|
|
141
|
+
if (r.sent || r.held)
|
|
142
|
+
o.log(`Replayed ${r.sent} queued write${r.sent === 1 ? '' : 's'}${r.held ? `, ${r.held} held back (see: agent-flow-board queue list)` : ''}.`);
|
|
143
|
+
};
|
|
144
|
+
async function handle(msg) {
|
|
145
|
+
try {
|
|
146
|
+
const res = await post(msg);
|
|
147
|
+
checkProtocol(res);
|
|
148
|
+
const body = await parse(res);
|
|
149
|
+
if (msg.id === undefined)
|
|
150
|
+
return;
|
|
151
|
+
if (body) {
|
|
152
|
+
if (msg.method === 'tools/list') {
|
|
153
|
+
const tools = body.result?.tools;
|
|
154
|
+
if (tools) {
|
|
155
|
+
readOnly = new Set(tools.filter((t) => t.annotations?.readOnlyHint).map((t) => t.name));
|
|
156
|
+
toolsKnown = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// An answer that failed before the server read the request (a refusal such as FORBIDDEN) carries no id: it answers this one
|
|
160
|
+
write(body.error && body.id == null ? { ...body, id: msg.id } : body);
|
|
161
|
+
void replay();
|
|
162
|
+
}
|
|
163
|
+
else
|
|
164
|
+
write({
|
|
165
|
+
jsonrpc: '2.0',
|
|
166
|
+
id: msg.id,
|
|
167
|
+
error: { code: -32603, message: `The board answered ${res.status} with no body.` }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (msg.id === undefined)
|
|
172
|
+
return;
|
|
173
|
+
if (err instanceof SignInNeeded) {
|
|
174
|
+
const text = 'The board did not accept the saved sign in. A sign in was started: open the address printed by the agent-flow-board bridge (or run "agent-flow-board login" in a terminal), type the code, then try again.';
|
|
175
|
+
if (msg.method === 'tools/call')
|
|
176
|
+
write({
|
|
177
|
+
jsonrpc: '2.0',
|
|
178
|
+
id: msg.id,
|
|
179
|
+
result: {
|
|
180
|
+
isError: true,
|
|
181
|
+
content: [{ type: 'text', text }],
|
|
182
|
+
structuredContent: { error: { code: 'SIGN_IN_NEEDED', message: text } }
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
else
|
|
186
|
+
write({ jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: text } });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (isTimeout(err)) {
|
|
190
|
+
write({
|
|
191
|
+
jsonrpc: '2.0',
|
|
192
|
+
id: msg.id,
|
|
193
|
+
error: {
|
|
194
|
+
code: -32000,
|
|
195
|
+
message: `The board did not answer within ${Math.round(postTimeout / 1000)} seconds. The request may or may not have been applied: read the item before sending it again.`
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!isNetworkError(err)) {
|
|
201
|
+
write({
|
|
202
|
+
jsonrpc: '2.0',
|
|
203
|
+
id: msg.id,
|
|
204
|
+
error: { code: -32603, message: String(err.message) }
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// The board cannot be reached. Reads say so, writes are queued and say they are queued.
|
|
209
|
+
const name = msg.params?.name;
|
|
210
|
+
if (msg.method === 'tools/call' && name && toolsKnown && !readOnly.has(name)) {
|
|
211
|
+
const q = o.queue.enqueue(name, msg.params?.arguments ?? {});
|
|
212
|
+
const out = {
|
|
213
|
+
queued: true,
|
|
214
|
+
queueId: q.id,
|
|
215
|
+
message: 'The board cannot be reached. This write was saved on this computer and will be sent when it is back, in order. Nothing was recorded yet.'
|
|
216
|
+
};
|
|
217
|
+
write({
|
|
218
|
+
jsonrpc: '2.0',
|
|
219
|
+
id: msg.id,
|
|
220
|
+
result: { content: [{ type: 'text', text: JSON.stringify(out) }], structuredContent: out }
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
write({
|
|
225
|
+
jsonrpc: '2.0',
|
|
226
|
+
id: msg.id,
|
|
227
|
+
error: {
|
|
228
|
+
code: -32000,
|
|
229
|
+
message: 'offline: the board cannot be reached, so this cannot be read right now.'
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const rl = createInterface({ input: o.stdin });
|
|
236
|
+
let chain = Promise.resolve();
|
|
237
|
+
rl.on('line', (line) => {
|
|
238
|
+
if (!line.trim())
|
|
239
|
+
return;
|
|
240
|
+
let msg;
|
|
241
|
+
try {
|
|
242
|
+
msg = JSON.parse(line);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// One message at a time keeps the order of writes exactly as the tool sent them
|
|
249
|
+
chain = chain.then(() => handle(msg));
|
|
250
|
+
});
|
|
251
|
+
const timer = o.replayEveryMs === 0 ? null : setInterval(() => void replay(), o.replayEveryMs ?? 15_000);
|
|
252
|
+
await new Promise((resolve) => rl.on('close', resolve));
|
|
253
|
+
await chain;
|
|
254
|
+
if (timer)
|
|
255
|
+
clearInterval(timer);
|
|
256
|
+
}
|
|
@@ -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
|
+
}
|