@agent-flow-board/bridge 0.1.0 → 0.3.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 +29 -1
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +126 -0
- package/dist/checks/bind.js +100 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +275 -0
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +267 -0
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +42 -0
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +212 -0
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +207 -0
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +85 -0
- package/dist/checks/preview.js +577 -0
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +246 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +443 -0
- package/dist/checks/sandbox.js +208 -0
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +383 -0
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +79 -0
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +228 -0
- package/dist/checks/wire.js +87 -0
- package/dist/cli.js +18 -1
- package/dist/hooks/adapters.js +3 -1
- package/dist/hooks/call.js +6 -1
- package/dist/hooks/modVersion.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,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
|
+
}
|