@agent-flow-board/bridge 0.2.0 → 0.4.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.
Files changed (54) hide show
  1. package/README.md +26 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +274 -0
  4. package/dist/checks/askBoard.js +143 -0
  5. package/dist/checks/audit.js +3 -2
  6. package/dist/checks/bind.js +3 -2
  7. package/dist/checks/board.js +30 -0
  8. package/dist/checks/broker.js +439 -0
  9. package/dist/checks/browser.js +137 -32
  10. package/dist/checks/browserpool.js +221 -0
  11. package/dist/checks/cache.js +710 -0
  12. package/dist/checks/command.js +126 -87
  13. package/dist/checks/commands.js +315 -0
  14. package/dist/checks/connections.js +4 -3
  15. package/dist/checks/copy.js +320 -0
  16. package/dist/checks/dbpool.js +710 -0
  17. package/dist/checks/doctor.js +655 -0
  18. package/dist/checks/envPolicy.js +50 -1
  19. package/dist/checks/envrules.js +322 -0
  20. package/dist/checks/evidence.js +114 -0
  21. package/dist/checks/fsretry.js +29 -0
  22. package/dist/checks/importlegacy.js +300 -0
  23. package/dist/checks/install.js +150 -0
  24. package/dist/checks/live.js +76 -0
  25. package/dist/checks/lock.js +28 -4
  26. package/dist/checks/login.js +307 -0
  27. package/dist/checks/mailguard-hook.cjs +216 -0
  28. package/dist/checks/mailguard.js +98 -0
  29. package/dist/checks/paths.js +24 -0
  30. package/dist/checks/preview.js +418 -191
  31. package/dist/checks/profiles.js +110 -0
  32. package/dist/checks/provision.js +1 -1
  33. package/dist/checks/recipe.js +19 -5
  34. package/dist/checks/registry.js +548 -0
  35. package/dist/checks/repoenv.js +162 -0
  36. package/dist/checks/roots.js +144 -0
  37. package/dist/checks/routing.js +68 -0
  38. package/dist/checks/run.js +243 -53
  39. package/dist/checks/sandbox.js +32 -10
  40. package/dist/checks/scheduler.js +383 -0
  41. package/dist/checks/scope.js +119 -30
  42. package/dist/checks/sealedNames.js +39 -0
  43. package/dist/checks/secrets.js +454 -0
  44. package/dist/checks/secretscmd.js +80 -0
  45. package/dist/checks/service.js +589 -0
  46. package/dist/checks/signin.js +7 -1
  47. package/dist/checks/signinKey.js +104 -0
  48. package/dist/checks/singleflight.js +307 -0
  49. package/dist/checks/store.js +92 -0
  50. package/dist/checks/surface.js +124 -0
  51. package/dist/checks/warm.js +342 -0
  52. package/dist/checks/watch.js +190 -63
  53. package/dist/checks/wire.js +87 -0
  54. package/package.json +1 -1
@@ -0,0 +1,104 @@
1
+ // THE MACHINE'S SIGN IN KEY: A P-256 KEY PAIR WHOSE PUBLIC HALF IS PUBLISHED TO THE BOARD, SO THE OWNER CAN TYPE A TEST ACCOUNT'S PASSWORD ON THE BOARD WITHOUT ANY AGENT SEEING IT
2
+ // The private half lives in the same sealed store as the saved sign ins (AES-GCM under the key in the bridge home) and is opened only in this process. A password is decrypted in memory,
3
+ // goes to the page through Playwright `fill` and is never an argument, an environment variable, a log line or an audit line. The format mirrors src/lib/shared/signinCrypto.ts.
4
+ // Honest limit: a process that runs as the same OS user can read the key files, like every other local secret here.
5
+ // -- DEP-MODULES -- //
6
+ import { createHash, webcrypto } from 'node:crypto';
7
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { appendAudit } from './audit.js';
11
+ import { checksHome, ensureDir } from './paths.js';
12
+ import { seal, unseal } from './store.js';
13
+ // -- CONSTANTS -- //
14
+ export const SIGNIN_ALG = 'ECDH-P256+HKDF-SHA256+AES-256-GCM';
15
+ const subtle = webcrypto.subtle;
16
+ const enc = new TextEncoder();
17
+ const keyFile = () => join(checksHome(), 'signin-key.bin');
18
+ const idFile = () => join(checksHome(), 'signin-key.id');
19
+ const AAD = 'signin-private-key';
20
+ // -- FUNCTIONS -- //
21
+ const b64u = (bytes) => Buffer.from(bytes).toString('base64url');
22
+ const unb64u = (text) => new Uint8Array(Buffer.from(text, 'base64url'));
23
+ /** The text that binds a ciphertext to where it may be opened. Must equal signinAad in src/lib/shared/signinCrypto.ts. */
24
+ export const signinAad = (orgId, runnerId, registration, keyId) => `afb-signin-v1|${orgId}|${runnerId}|${registration.toLowerCase()}|${keyId}`;
25
+ /** Twelve hex characters in groups of four, the same rule as the board and the browser. */
26
+ export function fingerprintOf(publicKey) {
27
+ const hex = createHash('sha256')
28
+ .update(Buffer.from(publicKey, 'base64url'))
29
+ .digest('hex')
30
+ .slice(0, 12);
31
+ return hex.match(/.{4}/g).join('-');
32
+ }
33
+ /** Makes the key pair once and keeps the private half sealed. Returns the id and the public half. */
34
+ export async function ensureSigninKey() {
35
+ ensureDir(checksHome());
36
+ if (existsSync(keyFile()) && existsSync(idFile())) {
37
+ const pair = readPair();
38
+ if (pair)
39
+ return pair;
40
+ }
41
+ const pair = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
42
+ 'deriveBits'
43
+ ]);
44
+ const pkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', pair.privateKey));
45
+ const pub = new Uint8Array(await subtle.exportKey('raw', pair.publicKey));
46
+ const keyId = b64u(webcrypto.getRandomValues(new Uint8Array(12)));
47
+ writeFileSync(keyFile(), seal(AAD, pkcs8), { mode: 0o600 });
48
+ writeFileSync(idFile(), JSON.stringify({ keyId, publicKey: b64u(pub) }), { mode: 0o600 });
49
+ pkcs8.fill(0);
50
+ appendAudit('signin key made', { keyId });
51
+ return { keyId, publicKey: b64u(pub) };
52
+ }
53
+ function readPair() {
54
+ try {
55
+ const v = JSON.parse(readFileSync(idFile(), 'utf8'));
56
+ return typeof v.keyId === 'string' && typeof v.publicKey === 'string'
57
+ ? { keyId: v.keyId, publicKey: v.publicKey }
58
+ : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /** Publishes the public key. The board accepts it only on this machine's own runner token; a changed key is shown there with its time. */
65
+ export async function publishSigninKey(board) {
66
+ const { keyId, publicKey } = await ensureSigninKey();
67
+ const done = await board.publishSigninKey({ keyId, publicKey });
68
+ return done;
69
+ }
70
+ /** The fingerprint of this machine's key, for `check status`, or null when none was made yet. */
71
+ export function localFingerprint() {
72
+ const pair = existsSync(idFile()) ? readPair() : null;
73
+ return pair ? fingerprintOf(pair.publicKey) : null;
74
+ }
75
+ /** Opens one envelope in memory. A ciphertext for another org, runner, registration or key fails here, because the associated data differs. */
76
+ export async function openSignin(envelope, where) {
77
+ const me = readPair();
78
+ if (!me || me.keyId !== envelope.keyId || envelope.alg !== SIGNIN_ALG)
79
+ throw new Error('That sign in was made for another key of this machine.');
80
+ const pkcs8 = new Uint8Array(unseal(AAD, new Uint8Array(readFileSync(keyFile()))));
81
+ let account;
82
+ try {
83
+ const priv = await subtle.importKey('pkcs8', pkcs8, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
84
+ const pack = JSON.parse(envelope.ciphertext);
85
+ const peer = await subtle.importKey('raw', unb64u(pack.epk), { name: 'ECDH', namedCurve: 'P-256' }, false, []);
86
+ const bits = await subtle.deriveBits({ name: 'ECDH', public: peer }, priv, 256);
87
+ const aad = enc.encode(signinAad(where.orgId, where.runnerId, envelope.registration, envelope.keyId));
88
+ const base = await subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
89
+ const key = await subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: aad }, base, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
90
+ const plain = await subtle.decrypt({ name: 'AES-GCM', iv: unb64u(pack.iv), additionalData: aad }, key, unb64u(pack.ct));
91
+ const v = JSON.parse(new TextDecoder().decode(plain));
92
+ if (typeof v.e !== 'string' || typeof v.p !== 'string')
93
+ throw new Error('shape');
94
+ account = { email: v.e, password: v.p };
95
+ }
96
+ catch {
97
+ // Never says why: nothing about the contents leaves this function
98
+ throw new Error('That sign in could not be opened on this machine.');
99
+ }
100
+ finally {
101
+ pkcs8.fill(0);
102
+ }
103
+ return account;
104
+ }
@@ -0,0 +1,307 @@
1
+ // SINGLE-FLIGHT FOR BUILDS AND INSTALLS ACROSS PROCESSES: WHEN SEVERAL RUNS NEED THE SAME THING, ONE BUILDS AND THE OTHERS WAIT FOR ITS RESULT.
2
+ // The lock is a file made whole and linked into place (like lock.ts). Its body holds the process id, the process start time, a token and a HEARTBEAT that the
3
+ // builder refreshes on an unref'd timer. A lock is stale only when its heartbeat is missing for `staleMs`: never because a process id looks dead or alive,
4
+ // because Windows reuses process ids. The builder writes its outcome to a result file named by its token BEFORE it gives the lock back, so a waiter that sees
5
+ // the lock go away reads what happened: a success is handed to every waiter, a deterministic failure is handed to every waiter as the same error, and an
6
+ // infrastructure failure (the work threw an error marked `infra: true`) makes one waiter the next builder. A builder that died leaves no result: the lock
7
+ // goes stale and one waiter takes it over. The work should be async (spawned processes), because a blocked event loop cannot beat.
8
+ // -- DEP-MODULES -- //
9
+ import { randomBytes } from 'node:crypto';
10
+ import { linkSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ // -- MODULES -- //
13
+ import { assertName, ensureDir, locksDir } from './paths.js';
14
+ /** Marks a failure of the machine (a full disk, a killed process, a network error) rather than of the code being built: one waiter tries again. */
15
+ export class InfraError extends Error {
16
+ infra = true;
17
+ constructor(message, options) {
18
+ super(message, options);
19
+ this.name = 'InfraError';
20
+ }
21
+ }
22
+ /** What every waiter gets when the builder failed deterministically: the same name and message, not a retry. */
23
+ export class SharedBuildError extends Error {
24
+ shared = true;
25
+ originalName;
26
+ constructor(name, message) {
27
+ super(message);
28
+ this.name = 'SharedBuildError';
29
+ this.originalName = name;
30
+ }
31
+ }
32
+ /** This caller waited as long as it was allowed to for a build that did not finish. */
33
+ export class FlightTimeoutError extends Error {
34
+ code = 'FLIGHT_TIMEOUT';
35
+ constructor(name, ms) {
36
+ super(`Gave up waiting ${Math.round(ms / 1000)} seconds for the build "${name}" of another run.`);
37
+ this.name = 'FlightTimeoutError';
38
+ }
39
+ }
40
+ // -- CONSTANTS -- //
41
+ /** A lock whose content cannot be read is only stale after this long (it is made whole, so this means a damaged file). */
42
+ const UNREADABLE_GRACE_MS = 2_000;
43
+ /** Result files are kept this long for slow waiters, then removed by the next builder. */
44
+ const RESULT_KEEP_MS = 10 * 60_000;
45
+ /** When this process started, to the nearest few milliseconds. */
46
+ const PROCESS_START = new Date(Date.now() - process.uptime() * 1000).toISOString();
47
+ // -- FUNCTIONS -- //
48
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
49
+ const lockFile = (name) => join(locksDir(), `sf-${assertName(name, 'flight name')}.lock`);
50
+ const resultFile = (name, token) => join(locksDir(), `sf-${assertName(name, 'flight name')}.${token}.result`);
51
+ function readText(file) {
52
+ try {
53
+ return readFileSync(file, 'utf8');
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ function parseBody(text) {
60
+ if (text === null)
61
+ return null;
62
+ try {
63
+ const v = JSON.parse(text);
64
+ if (!v ||
65
+ typeof v.pid !== 'number' ||
66
+ typeof v.start !== 'string' ||
67
+ typeof v.beat !== 'string' ||
68
+ typeof v.token !== 'string')
69
+ return null;
70
+ return { pid: v.pid, start: v.start, beat: v.beat, token: v.token };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /** Stale by a missing heartbeat only. The process id is never looked at. */
77
+ function isStale(text, file, staleMs) {
78
+ const body = parseBody(text);
79
+ if (body) {
80
+ const beat = Date.parse(body.beat);
81
+ return !Number.isFinite(beat) || Date.now() - beat > staleMs;
82
+ }
83
+ try {
84
+ return Date.now() - statSync(file).mtimeMs > UNREADABLE_GRACE_MS;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ /** Writes a file whole: a temporary file renamed into place, so a reader never sees half of it. */
91
+ function writeWhole(file, text) {
92
+ const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
93
+ writeFileSync(tmp, text, { mode: 0o600 });
94
+ try {
95
+ renameSync(tmp, file);
96
+ }
97
+ catch (e) {
98
+ rmSync(tmp, { force: true });
99
+ throw e;
100
+ }
101
+ }
102
+ /** Creates the lock file with its whole content at once. False when somebody holds it. */
103
+ function tryCreate(file, text) {
104
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
105
+ writeFileSync(tmp, text, { mode: 0o600 });
106
+ try {
107
+ linkSync(tmp, file);
108
+ return true;
109
+ }
110
+ catch (e) {
111
+ if (e.code === 'EEXIST')
112
+ return false;
113
+ throw e;
114
+ }
115
+ finally {
116
+ rmSync(tmp, { force: true });
117
+ }
118
+ }
119
+ /** Moves the lock aside in one step, so of two contenders only one moves it. A fresh lock that was moved by mistake is linked back. */
120
+ function moveAside(file, expected, staleMs) {
121
+ const aside = `${file}.aside.${process.pid}.${randomBytes(4).toString('hex')}`;
122
+ try {
123
+ renameSync(file, aside);
124
+ }
125
+ catch {
126
+ return;
127
+ }
128
+ const moved = readText(aside);
129
+ if (moved !== null && moved !== expected && !isStale(moved, aside, staleMs)) {
130
+ try {
131
+ linkSync(aside, file);
132
+ }
133
+ catch {
134
+ /* A newer lock is already there */
135
+ }
136
+ }
137
+ rmSync(aside, { force: true });
138
+ }
139
+ const bodyText = (token, beat) => JSON.stringify({
140
+ pid: process.pid,
141
+ start: PROCESS_START,
142
+ beat: beat.toISOString(),
143
+ token
144
+ }) + '\n';
145
+ /** Takes the lock without waiting. Returns the token, or null when somebody with a fresh heartbeat holds it. */
146
+ function tryAcquire(name, staleMs) {
147
+ const file = lockFile(name);
148
+ ensureDir(locksDir());
149
+ const token = randomBytes(8).toString('hex');
150
+ const mine = bodyText(token, new Date());
151
+ for (let attempt = 0; attempt < 6; attempt++) {
152
+ if (tryCreate(file, mine))
153
+ return token;
154
+ const held = readText(file);
155
+ if (held === null)
156
+ continue; // Released between the two looks
157
+ if (!isStale(held, file, staleMs))
158
+ return null;
159
+ moveAside(file, held, staleMs);
160
+ }
161
+ return null;
162
+ }
163
+ /** Gives the lock back, but only when it still carries our token (it may have been taken over after a long pause). */
164
+ function release(name, token, staleMs) {
165
+ const file = lockFile(name);
166
+ const text = readText(file);
167
+ if (parseBody(text)?.token !== token)
168
+ return;
169
+ const aside = `${file}.aside.${process.pid}.${randomBytes(4).toString('hex')}`;
170
+ try {
171
+ renameSync(file, aside);
172
+ }
173
+ catch {
174
+ return;
175
+ }
176
+ const moved = readText(aside);
177
+ if (moved !== null && parseBody(moved)?.token !== token && !isStale(moved, aside, staleMs)) {
178
+ try {
179
+ linkSync(aside, file);
180
+ }
181
+ catch {
182
+ /* A newer lock is already there */
183
+ }
184
+ }
185
+ rmSync(aside, { force: true });
186
+ }
187
+ /** Refreshes the heartbeat of our lock. Returns false when the lock is no longer ours. */
188
+ function beat(name, token) {
189
+ const file = lockFile(name);
190
+ const body = parseBody(readText(file));
191
+ if (!body)
192
+ return true; // Unreadable for a moment: try again on the next beat
193
+ if (body.token !== token)
194
+ return false;
195
+ try {
196
+ writeWhole(file, bodyText(token, new Date()));
197
+ }
198
+ catch {
199
+ /* Windows may refuse the swap while a reader has the file open: the next beat tries again */
200
+ }
201
+ return true;
202
+ }
203
+ function readOutcome(name, token) {
204
+ const text = readText(resultFile(name, token));
205
+ if (text === null)
206
+ return null;
207
+ try {
208
+ const v = JSON.parse(text);
209
+ return v && typeof v === 'object' && typeof v.ok === 'boolean' ? v : null;
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ function purgeOldResults(name) {
216
+ const prefix = `sf-${name}.`;
217
+ try {
218
+ for (const n of readdirSync(locksDir())) {
219
+ if (!n.startsWith(prefix) || !n.endsWith('.result'))
220
+ continue;
221
+ const f = join(locksDir(), n);
222
+ try {
223
+ if (Date.now() - statSync(f).mtimeMs > RESULT_KEEP_MS)
224
+ rmSync(f, { force: true });
225
+ }
226
+ catch {
227
+ /* Gone */
228
+ }
229
+ }
230
+ }
231
+ catch {
232
+ /* No folder yet */
233
+ }
234
+ }
235
+ const isInfra = (e) => !!e && typeof e === 'object' && e.infra === true;
236
+ /**
237
+ * Runs `work` once for everybody who asks for `name` at the same time, across processes. The caller that takes the lock is the builder and its `work`
238
+ * result (which must be JSON-serialisable, for example a cache key) is handed to the waiters. See the header of this file for failures and crashes.
239
+ */
240
+ export async function singleFlight(name, work, opts = {}) {
241
+ const heartbeatMs = opts.heartbeatMs ?? 2_000;
242
+ const staleMs = Math.max(opts.staleMs ?? 15_000, heartbeatMs * 5);
243
+ const waiterLimitMs = opts.waiterLimitMs ?? 10 * 60_000;
244
+ const pollMs = opts.pollMs ?? 100;
245
+ lockFile(name); // Refuses a name that is not plain before anything is made
246
+ const deadline = Date.now() + waiterLimitMs;
247
+ let watched = null;
248
+ const handled = new Set();
249
+ for (;;) {
250
+ // 1. The outcome of the build we are waiting for, written before its lock was given back
251
+ if (watched && !handled.has(watched)) {
252
+ const out = readOutcome(name, watched);
253
+ if (out) {
254
+ if (out.ok)
255
+ return { role: 'waiter', result: out.value };
256
+ if (!out.infra)
257
+ throw new SharedBuildError(out.name, out.message);
258
+ handled.add(watched); // An infrastructure failure: whoever takes the lock first builds again
259
+ watched = null;
260
+ }
261
+ }
262
+ if (opts.signal?.aborted)
263
+ throw new Error('The run was stopped.');
264
+ // 2. Take the lock and build
265
+ const token = tryAcquire(name, staleMs);
266
+ if (token)
267
+ return runBuilder(name, token, work, heartbeatMs, staleMs);
268
+ // 3. Somebody else builds: remember which build, look again soon
269
+ const body = parseBody(readText(lockFile(name)));
270
+ if (body && !handled.has(body.token))
271
+ watched = body.token;
272
+ if (Date.now() >= deadline)
273
+ throw new FlightTimeoutError(name, waiterLimitMs);
274
+ await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
275
+ }
276
+ }
277
+ async function runBuilder(name, token, work, heartbeatMs, staleMs) {
278
+ purgeOldResults(name);
279
+ let lost = false;
280
+ const timer = setInterval(() => {
281
+ if (!lost && !beat(name, token))
282
+ lost = true;
283
+ }, heartbeatMs);
284
+ timer.unref();
285
+ const record = (outcome) => {
286
+ try {
287
+ writeWhole(resultFile(name, token), JSON.stringify(outcome));
288
+ }
289
+ catch {
290
+ /* Waiters then find no outcome and the lock going away: the next one builds */
291
+ }
292
+ };
293
+ try {
294
+ const value = await work();
295
+ record({ ok: true, value: value ?? null });
296
+ return { role: 'builder', result: value };
297
+ }
298
+ catch (e) {
299
+ const err = e instanceof Error ? e : new Error(String(e));
300
+ record({ ok: false, infra: isInfra(e), name: err.name, message: err.message });
301
+ throw e;
302
+ }
303
+ finally {
304
+ clearInterval(timer);
305
+ release(name, token, staleMs);
306
+ }
307
+ }
@@ -0,0 +1,92 @@
1
+ // THE SEALED STORE: AES-256-GCM WITH A KEY THAT LIVES IN THE BRIDGE HOME, NEVER INSIDE THE CHECKS FOLDER, A RUN, A COPY OR A PATH A PREVIEW ENV NAMES.
2
+ // seal() binds a text (`aad`, for example the registration org/NAME) into the seal without storing it, so a sealed file opens only for the same text and a copy made for
3
+ // another registration fails. The blob is iv (12 bytes) | tag (16 bytes) | ciphertext. Nothing here ever prints or returns a key.
4
+ // -- DEP-MODULES -- //
5
+ import { execFileSync } from 'node:child_process';
6
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { configDir } from '../tokenStore.js';
11
+ // -- CONSTANTS -- //
12
+ const IV_BYTES = 12;
13
+ const TAG_BYTES = 16;
14
+ const KEY_BYTES = 32;
15
+ // -- FUNCTIONS -- //
16
+ /** The key file sits beside the token store in the bridge home, one level above the checks folder. */
17
+ export const sealKeyFile = () => join(configDir(), 'checks.key');
18
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
19
+ /** Limits the key file to the current user on Windows (icacls on this one file only; the folder and its inheritance are left alone). Best effort. */
20
+ function lockDownFile(file) {
21
+ try {
22
+ if (process.platform === 'win32') {
23
+ const user = process.env.USERNAME;
24
+ if (!user)
25
+ return;
26
+ execFileSync('icacls', [file, '/inheritance:r', '/grant:r', `${user}:F`], {
27
+ stdio: 'ignore',
28
+ windowsHide: true,
29
+ timeout: 10_000
30
+ });
31
+ }
32
+ else
33
+ chmodSync(file, 0o600);
34
+ }
35
+ catch {
36
+ /* Not fatal: the key is still outside every folder an agent or a check works in */
37
+ }
38
+ }
39
+ /**
40
+ * The key. Several processes may start at once on a machine that has none yet: the file is made exclusively (the first one wins) and a key that is
41
+ * still being written is waited for. A file that stays the wrong size is damaged and named, never replaced.
42
+ */
43
+ function key() {
44
+ const file = sealKeyFile();
45
+ for (let i = 0; i < 80; i++) {
46
+ if (!existsSync(file)) {
47
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
48
+ try {
49
+ writeFileSync(file, randomBytes(KEY_BYTES), { mode: 0o600, flag: 'wx' });
50
+ lockDownFile(file);
51
+ }
52
+ catch (e) {
53
+ if (e.code !== 'EEXIST')
54
+ throw e;
55
+ }
56
+ }
57
+ let k = null;
58
+ try {
59
+ k = readFileSync(file);
60
+ }
61
+ catch {
62
+ /* Gone again or being written: wait */
63
+ }
64
+ if (k && k.length === KEY_BYTES)
65
+ return k;
66
+ nap(25);
67
+ }
68
+ throw new Error(`The sealing key file (checks.key in the bridge home) is damaged. Nothing sealed with it can be opened; remove the file and import the secrets again.`);
69
+ }
70
+ /** Seals `plaintext` for `aad`. */
71
+ export function seal(aad, plaintext) {
72
+ const iv = randomBytes(IV_BYTES);
73
+ const c = createCipheriv('aes-256-gcm', key(), iv);
74
+ c.setAAD(Buffer.from(aad, 'utf8'));
75
+ const ct = Buffer.concat([c.update(plaintext), c.final()]);
76
+ return Buffer.concat([iv, c.getAuthTag(), ct]);
77
+ }
78
+ /** Opens a blob made by seal() for the same `aad`. Throws one plain sentence when the blob is damaged or was made for another text. */
79
+ export function unseal(aad, blob) {
80
+ const b = Buffer.from(blob);
81
+ if (b.length < IV_BYTES + TAG_BYTES)
82
+ throw new Error('The sealed data is too short to be valid.');
83
+ try {
84
+ const d = createDecipheriv('aes-256-gcm', key(), b.subarray(0, IV_BYTES));
85
+ d.setAAD(Buffer.from(aad, 'utf8'));
86
+ d.setAuthTag(b.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
87
+ return Buffer.concat([d.update(b.subarray(IV_BYTES + TAG_BYTES)), d.final()]);
88
+ }
89
+ catch {
90
+ throw new Error('The sealed data is damaged or was sealed for something else.');
91
+ }
92
+ }
@@ -0,0 +1,124 @@
1
+ // THE FILES THAT DECIDE WHAT A BUILD AND A START RUN. A registration keeps a hash of them (its pin), so a change to a script, a lockfile, the package manager's own program or a
2
+ // build config is noticed and the commands are checked against the guardrails again before the new hash is pinned. This is change detection for the build setup, not a defence
3
+ // against the agent: the preview runs the app's own code, which the agent writes.
4
+ // -- DEP-MODULES -- //
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
7
+ import { join, relative, resolve, sep } from 'node:path';
8
+ // -- CONSTANTS -- //
9
+ const FIXED = [
10
+ 'yarn.lock',
11
+ 'package-lock.json',
12
+ 'pnpm-lock.yaml',
13
+ 'pnpm-workspace.yaml',
14
+ '.pnpmfile.cjs',
15
+ '.yarnrc.yml',
16
+ '.npmrc',
17
+ '.afb-checks.json'
18
+ ];
19
+ /** Files at the folder root that tools load as code when they build: vite.config.ts, svelte.config.js, postcss.config.cjs and the like. */
20
+ const CONFIG = /\.config\.(c|m)?[jt]s$|^\.postcssrc/;
21
+ /** Folders whose files run as the package manager. */
22
+ const PROGRAM_DIRS = ['.yarn/releases', '.yarn/plugins'];
23
+ // -- FUNCTIONS -- //
24
+ /** What a path is, without following a link: a link or an unreadable entry still counts, so it can never be skipped or crash the hash. */
25
+ function kind(p) {
26
+ try {
27
+ const s = lstatSync(p);
28
+ return s.isSymbolicLink() ? 'link' : s.isDirectory() ? 'dir' : 'file';
29
+ }
30
+ catch {
31
+ return 'unreadable';
32
+ }
33
+ }
34
+ function text(file) {
35
+ try {
36
+ return readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
37
+ }
38
+ catch {
39
+ return '\0unreadable';
40
+ }
41
+ }
42
+ /** A file as it goes into the hash: its content, or the fact that it is a link or cannot be read. */
43
+ const body = (file) => (kind(file) === 'file' ? text(file) : `\0${kind(file)}`);
44
+ function filesIn(dir, base) {
45
+ const out = [];
46
+ if (!existsSync(dir))
47
+ return out;
48
+ let names;
49
+ try {
50
+ names = readdirSync(dir).sort();
51
+ }
52
+ catch {
53
+ return [relative(base, dir).split(sep).join('/')];
54
+ }
55
+ for (const name of names) {
56
+ const full = join(dir, name);
57
+ if (kind(full) === 'dir')
58
+ out.push(...filesIn(full, base));
59
+ else
60
+ out.push(relative(base, full).split(sep).join('/'));
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * The hash of the surface of an app folder and the names of the files that went into it. When the app sits in a subfolder of the git root (`parent`), the package
66
+ * manager's settings of the root also count (yarn merges .yarnrc.yml upward), so the root's fixed files and program folders are hashed under a `root:` prefix.
67
+ */
68
+ export function surfaceHash(repo, parent = null) {
69
+ const names = [];
70
+ const parts = [];
71
+ const pkg = join(repo, 'package.json');
72
+ if (kind(pkg) !== 'unreadable') {
73
+ let scripts;
74
+ try {
75
+ const parsed = JSON.parse(text(pkg));
76
+ scripts =
77
+ parsed && typeof parsed === 'object'
78
+ ? (parsed.scripts ?? {})
79
+ : {};
80
+ }
81
+ catch {
82
+ scripts = { __unreadable: true };
83
+ }
84
+ names.push('package.json#scripts');
85
+ parts.push(`package.json#scripts\0${JSON.stringify(scripts)}`);
86
+ }
87
+ const list = (dir) => {
88
+ try {
89
+ return existsSync(dir) ? readdirSync(dir) : [];
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ };
95
+ const rootNames = list(repo);
96
+ const listed = [
97
+ ...new Set([
98
+ ...FIXED.filter((f) => rootNames.includes(f)),
99
+ ...rootNames.filter((f) => CONFIG.test(f))
100
+ ])
101
+ ].sort();
102
+ for (const f of listed) {
103
+ names.push(f);
104
+ parts.push(`${f}\0${body(join(repo, f))}`);
105
+ }
106
+ for (const d of PROGRAM_DIRS)
107
+ for (const f of filesIn(join(repo, d), repo)) {
108
+ names.push(f);
109
+ parts.push(`${f}\0${body(join(repo, f))}`);
110
+ }
111
+ if (parent && resolve(parent) !== resolve(repo)) {
112
+ const parentNames = list(parent);
113
+ for (const f of FIXED.filter((n) => parentNames.includes(n) && n !== '.afb-checks.json')) {
114
+ names.push(`root:${f}`);
115
+ parts.push(`root:${f}\0${body(join(parent, f))}`);
116
+ }
117
+ for (const d of PROGRAM_DIRS)
118
+ for (const f of filesIn(join(parent, d), parent)) {
119
+ names.push(`root:${f}`);
120
+ parts.push(`root:${f}\0${body(join(parent, f))}`);
121
+ }
122
+ }
123
+ return { hash: createHash('sha256').update(parts.join('\n')).digest('hex'), files: names };
124
+ }