@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.
Files changed (62) hide show
  1. package/README.md +29 -1
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +126 -0
  5. package/dist/checks/bind.js +100 -0
  6. package/dist/checks/board.js +107 -0
  7. package/dist/checks/broker.js +439 -0
  8. package/dist/checks/browser.js +275 -0
  9. package/dist/checks/browserpool.js +221 -0
  10. package/dist/checks/cache.js +710 -0
  11. package/dist/checks/child.js +211 -0
  12. package/dist/checks/command.js +267 -0
  13. package/dist/checks/commands.js +219 -0
  14. package/dist/checks/connect.js +79 -0
  15. package/dist/checks/connections.js +42 -0
  16. package/dist/checks/copy.js +320 -0
  17. package/dist/checks/dbpool.js +710 -0
  18. package/dist/checks/doctor.js +649 -0
  19. package/dist/checks/envPolicy.js +212 -0
  20. package/dist/checks/envrules.js +322 -0
  21. package/dist/checks/evidence.js +114 -0
  22. package/dist/checks/fsretry.js +29 -0
  23. package/dist/checks/guard.js +188 -0
  24. package/dist/checks/guardrails.js +155 -0
  25. package/dist/checks/importlegacy.js +300 -0
  26. package/dist/checks/install.js +150 -0
  27. package/dist/checks/live.js +76 -0
  28. package/dist/checks/lock.js +207 -0
  29. package/dist/checks/login.js +203 -0
  30. package/dist/checks/mailguard-hook.cjs +216 -0
  31. package/dist/checks/mailguard.js +98 -0
  32. package/dist/checks/paths.js +85 -0
  33. package/dist/checks/preview.js +577 -0
  34. package/dist/checks/profiles.js +110 -0
  35. package/dist/checks/provision.js +175 -0
  36. package/dist/checks/recipe.js +246 -0
  37. package/dist/checks/redact.js +52 -0
  38. package/dist/checks/registry.js +548 -0
  39. package/dist/checks/repoenv.js +162 -0
  40. package/dist/checks/roots.js +144 -0
  41. package/dist/checks/routing.js +68 -0
  42. package/dist/checks/run.js +443 -0
  43. package/dist/checks/sandbox.js +208 -0
  44. package/dist/checks/scheduler.js +383 -0
  45. package/dist/checks/scope.js +383 -0
  46. package/dist/checks/sealedNames.js +39 -0
  47. package/dist/checks/secrets.js +452 -0
  48. package/dist/checks/secretscmd.js +80 -0
  49. package/dist/checks/service.js +589 -0
  50. package/dist/checks/signin.js +79 -0
  51. package/dist/checks/singleflight.js +307 -0
  52. package/dist/checks/store.js +92 -0
  53. package/dist/checks/surface.js +124 -0
  54. package/dist/checks/types.js +2 -0
  55. package/dist/checks/warm.js +342 -0
  56. package/dist/checks/watch.js +228 -0
  57. package/dist/checks/wire.js +87 -0
  58. package/dist/cli.js +18 -1
  59. package/dist/hooks/adapters.js +3 -1
  60. package/dist/hooks/call.js +6 -1
  61. package/dist/hooks/modVersion.js +26 -0
  62. package/package.json +2 -2
@@ -0,0 +1,577 @@
1
+ // THE PREVIEW OF ONE RUN: A PRIVATE COPY OF THE REPO, BUILT AND SERVED FROM ITS OWN FOLDER ON A FREE PORT, WITH AN ENVIRONMENT THE TOOL BUILT (envPolicy.ts).
2
+ // A run builds and serves from its own copy so it can never take down the preview a person is looking at, and two runs never share a folder. The copy
3
+ // is a local clone of the git root at HEAD plus the working-tree changes (copy.ts); env files are not copied, they are read from the source repo.
4
+ // With an app folder the copy is still made from the git root, but install, build and start run in the app folder of the copy. A live site (cfg.url)
5
+ // builds and starts nothing: the run is unverified. A dependency cache is not made here: a fresh install per run cannot serve a stale module.
6
+ // -- DEP-MODULES -- //
7
+ import { spawn } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+ import { closeSync, lstatSync, openSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ // -- MODULES -- //
12
+ import { appDirProblem, sandboxAppRoot } from './appdir.js';
13
+ import { git, listFiles, makePrivateCopy } from './copy.js';
14
+ import { buildAdoptedEnv, buildPreviewEnv } from './envPolicy.js';
15
+ import { describeFence, fenceProblem, readFence, withMailGuard } from './mailguard.js';
16
+ import { omitKeys, resolveSealedEnv } from './secrets.js';
17
+ import { FORBIDDEN_PORTS } from './guardrails.js';
18
+ import { KEEP_BUILDS, KEEP_DEPS, buildKey, depsKey, evict, hasEntry, publishBuildAsync, publishDepsAsync, restoreBuildAsync, restoreDepsAsync, restoreOrNull, snapshotAsync, treeId } from './cache.js';
19
+ import { installCacheable, installCommand, installDirFor, lockHash, packageManagerOf, packageManagerVersion, pathOf, programArgv } from './install.js';
20
+ import { cacheDirFor, copiesDir, ensureDir, logDirFor, runsDir, under } from './paths.js';
21
+ import { pinFiles } from './recipe.js';
22
+ import { redact as redactText } from './redact.js';
23
+ import { InfraError, singleFlight } from './singleflight.js';
24
+ import { provisionDatabase } from './dbpool.js';
25
+ import { freePort } from './provision.js';
26
+ import { killTree, removeTree } from './scope.js';
27
+ // -- CONSTANTS -- //
28
+ const BUILD_TIMEOUT_MS = 10 * 60_000;
29
+ const READY_TIMEOUT_MS = 120_000;
30
+ /** Env files are never part of what is hashed. */
31
+ const ENV_FILE = /(^|\/)\.env(\.[^/]*)?$/i;
32
+ /** Host names that mean this machine. */
33
+ const LOOPBACK = new Set([
34
+ 'localhost',
35
+ '127.0.0.1',
36
+ '::1',
37
+ '[::1]',
38
+ '0.0.0.0',
39
+ 'host.docker.internal'
40
+ ]);
41
+ // -- FUNCTIONS -- //
42
+ /** A hash over the uncommitted changes (`git diff HEAD`) and the contents of untracked files. Null when the folder is not a git repo. */
43
+ async function hashChanges(repo) {
44
+ if (!(await listFiles(repo)))
45
+ return null;
46
+ const h = createHash('sha256');
47
+ const diff = await git(repo, ['diff', 'HEAD']);
48
+ if (diff.status === 0)
49
+ h.update(diff.stdout);
50
+ const others = await git(repo, ['ls-files', '--others', '--exclude-standard', '-z']);
51
+ for (const rel of others.stdout.toString('utf8').split('\0').filter(Boolean).sort()) {
52
+ try {
53
+ const full = join(repo, ...rel.split('/'));
54
+ if (ENV_FILE.test(rel) || !lstatSync(full).isFile())
55
+ continue;
56
+ h.update(`\0${rel}\0`);
57
+ h.update(readFileSync(full));
58
+ }
59
+ catch {
60
+ /* A file that vanished while hashing */
61
+ }
62
+ }
63
+ return h.digest('hex');
64
+ }
65
+ /**
66
+ * Starts a program from an argument list with no shell. On Windows the package managers are .cmd shims, which a plain spawn cannot find (ENOENT) and
67
+ * Node refuses to start directly; those go through `cmd.exe /d /s /c` (install.ts programArgv). The arguments came from a command that was already
68
+ * checked (words, flags and a port, no metacharacters), so they are not interpreted by cmd.exe. Resolves once the process exists.
69
+ */
70
+ export function launch(argv, opts) {
71
+ const [file, ...args] = programArgv(argv, { pathEnv: pathOf(opts.env) });
72
+ if (!file)
73
+ return Promise.reject(new Error('The command is empty.'));
74
+ return new Promise((resolve, reject) => {
75
+ const child = spawn(file, args, {
76
+ cwd: opts.cwd,
77
+ env: opts.env,
78
+ stdio: ['ignore', opts.out, opts.out],
79
+ windowsHide: true,
80
+ shell: false,
81
+ detached: opts.detached
82
+ });
83
+ child.once('error', reject);
84
+ child.once('spawn', () => {
85
+ child.off('error', reject);
86
+ resolve(child);
87
+ });
88
+ });
89
+ }
90
+ /** The message of a stopped run. */
91
+ const STOPPED = 'The run was stopped.';
92
+ /**
93
+ * The error of an install or build command that ended without success. No exit code at all (-1: it timed out, was killed or was stopped) is the machine's trouble and
94
+ * not the code's, so it is an InfraError: a run that waited for this one takes over instead of being handed the same failure. A real exit code is the code's failure
95
+ * and is shared with every waiter, with the last lines of the log.
96
+ */
97
+ export function stageError(what, code, aborted, tail) {
98
+ if (code === -1)
99
+ return new InfraError(aborted ? STOPPED : `The ${what} timed out or was stopped.`);
100
+ return new Error(`The ${what} failed (exit ${code}). Last lines:\n${tail()}`);
101
+ }
102
+ /**
103
+ * Runs a command to its end and resolves with its exit code (or -1 when it timed out or was killed). The output goes to the open file `out`.
104
+ * The process is registered on the scope while it runs, so a stop signal or a crash of the run cannot leave it (or the cmd.exe and node under it) behind;
105
+ * an abort kills its whole tree at once. A command that cannot be started at all (no such program, no disk, no handles) is an InfraError.
106
+ */
107
+ export async function runToEnd(argv, opts) {
108
+ if (opts.signal?.aborted)
109
+ throw new InfraError(STOPPED);
110
+ let child;
111
+ try {
112
+ child = await launch(argv, { ...opts, detached: false });
113
+ }
114
+ catch (e) {
115
+ throw new InfraError(`The command could not be started: ${e.code ?? 'an error'}.`, { cause: e });
116
+ }
117
+ let gone = false;
118
+ const pid = child.pid;
119
+ if (pid)
120
+ opts.scope?.add(opts.name ?? 'build process', () => (gone ? undefined : killTree(pid)));
121
+ return new Promise((resolve) => {
122
+ const finish = (code) => {
123
+ gone = true;
124
+ clearTimeout(timer);
125
+ opts.signal?.removeEventListener('abort', onAbort);
126
+ resolve(code);
127
+ };
128
+ // A process the tool killed (a timeout, an abort) is -1 whatever code the kill made it exit with (taskkill ends it with 1): the exit event
129
+ // can come before the kill call returns
130
+ let killed = false;
131
+ const kill = () => {
132
+ killed = true;
133
+ if (pid && !gone)
134
+ void killTree(pid).then(() => finish(-1));
135
+ else
136
+ finish(-1);
137
+ };
138
+ const onAbort = () => kill();
139
+ const timer = setTimeout(kill, opts.timeoutMs);
140
+ child.once('exit', (code) => finish(killed ? -1 : (code ?? -1)));
141
+ if (opts.signal) {
142
+ if (opts.signal.aborted)
143
+ kill();
144
+ else
145
+ opts.signal.addEventListener('abort', onAbort, { once: true });
146
+ }
147
+ });
148
+ }
149
+ const words = (command) => command.split(' ').filter(Boolean);
150
+ /** Logs how long one stage of a run took, as `Stage <name>: <seconds>s` (the benchmark reads these lines). */
151
+ function stageDone(log, name, since) {
152
+ log(`Stage ${name}: ${((Date.now() - since) / 1000).toFixed(1)}s`);
153
+ }
154
+ /** The last lines of a log file with every value of the preview environment hidden. */
155
+ export function tailOf(file, secrets, lines = 20) {
156
+ let text;
157
+ try {
158
+ text = readFileSync(file, 'utf8');
159
+ }
160
+ catch {
161
+ return '';
162
+ }
163
+ let out = text.split(/\r?\n/).filter(Boolean).slice(-lines).join('\n');
164
+ for (const s of [...secrets].sort((a, b) => b.length - a.length))
165
+ if (s.length >= 4)
166
+ out = out.split(s).join('[hidden]');
167
+ // The raw value is not the only way a secret shows in a log: URL encoded, base64 and the shape of a token are removed as well
168
+ return redactText(out, secrets);
169
+ }
170
+ /** Waits until GET url answers with a status below 500. Gives up after `timeoutMs`, at once when `exited` says the process is gone, or when the signal aborts. */
171
+ export async function waitReady(url, opts) {
172
+ const until = Date.now() + opts.timeoutMs;
173
+ while (Date.now() < until) {
174
+ if (opts.signal?.aborted)
175
+ return 'aborted';
176
+ if (opts.exited())
177
+ return 'exited';
178
+ try {
179
+ const res = await fetch(url, { signal: AbortSignal.timeout(2_000), redirect: 'manual' });
180
+ await res.body?.cancel();
181
+ if (res.status < 500)
182
+ return 'ready';
183
+ }
184
+ catch {
185
+ /* Not listening yet */
186
+ }
187
+ await new Promise((r) => setTimeout(r, opts.intervalMs ?? 500));
188
+ }
189
+ if (opts.signal?.aborted)
190
+ return 'aborted';
191
+ return opts.exited() ? 'exited' : 'timeout';
192
+ }
193
+ /** Picks a free port on 127.0.0.1 and refuses a forbidden one. */
194
+ export async function pickPort() {
195
+ const port = await freePort();
196
+ if (FORBIDDEN_PORTS.has(port))
197
+ throw new Error(`Port ${port} is not one a check may use.`);
198
+ return port;
199
+ }
200
+ /**
201
+ * The preview of a live site: nothing is copied, built or started. A loopback address on a port a check never uses is refused. The run is unverified:
202
+ * there is no commit and no diff hash, and the note says so.
203
+ */
204
+ export function liveSitePreview(cfg) {
205
+ let u;
206
+ try {
207
+ u = new URL(String(cfg.url));
208
+ }
209
+ catch {
210
+ throw new Error('The live site address is not a web address.');
211
+ }
212
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
213
+ throw new Error('The live site address must start with http:// or https://.');
214
+ if (LOOPBACK.has(u.hostname.toLowerCase())) {
215
+ const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80;
216
+ if (FORBIDDEN_PORTS.has(port))
217
+ throw new Error(`Port ${port} is not one a check may use.`);
218
+ }
219
+ return {
220
+ baseUrl: cfg.url,
221
+ commit: null,
222
+ diffHash: null,
223
+ testUser: cfg.testUser ?? null,
224
+ secrets: cfg.testUser ? [cfg.testUser.password] : [],
225
+ note: 'This is a live site: nothing was built or started, so the run is unverified (no commit is recorded).'
226
+ };
227
+ }
228
+ export async function startPreview(cfg, opts) {
229
+ const { runId, scope, log, signal } = opts;
230
+ const stopIfAborted = () => {
231
+ if (signal?.aborted)
232
+ throw new Error(STOPPED);
233
+ };
234
+ // -- 0. A LIVE SITE: NOTHING TO MAKE -- //
235
+ if (cfg.url)
236
+ return liveSitePreview(cfg);
237
+ // The app folder is checked in the live repo first, so a bad one stops before anything is copied
238
+ if (cfg.appDir) {
239
+ const problem = appDirProblem(cfg.repo, cfg.appDir);
240
+ if (problem)
241
+ throw new Error(problem);
242
+ }
243
+ // -- 1. THE MARKER COMES FIRST -- //
244
+ scope.start();
245
+ // The run folder is the child's to write; the logs hold what the app printed and live where the child cannot read (paths.ts logsDir)
246
+ ensureDir(under(runsDir(), runId));
247
+ const logDir = ensureDir(logDirFor(runId));
248
+ const notes = [];
249
+ const secrets = [];
250
+ const known = opts.needles ?? [];
251
+ const learn = (...values) => {
252
+ for (const v of values)
253
+ if (typeof v === 'string' && v.length >= 6 && !known.includes(v))
254
+ known.push(v);
255
+ };
256
+ // -- 2. A PRIVATE COPY -- //
257
+ const copyDir = under(copiesDir(), runId);
258
+ scope.setInfo({ copyDir });
259
+ scope.add('private copy', () => removeTree(copyDir));
260
+ const copyStart = Date.now();
261
+ // The tree is hashed before the copy and again after it: a file saved while the copy was made would otherwise be tested but described by another hash
262
+ let hashBefore = await hashChanges(cfg.repo);
263
+ let calls = 0;
264
+ const copyOnce = () => makePrivateCopy(cfg.repo, copyDir, {
265
+ log,
266
+ afterCopy: opts.afterCopy ? () => opts.afterCopy?.(++calls) : undefined
267
+ });
268
+ let made = await copyOnce();
269
+ let diffHash = await hashChanges(cfg.repo);
270
+ if (diffHash !== hashBefore) {
271
+ log('The repo changed while it was being copied; copying again.');
272
+ removeTree(copyDir);
273
+ hashBefore = diffHash;
274
+ made = await copyOnce();
275
+ diffHash = await hashChanges(cfg.repo);
276
+ if (diffHash !== hashBefore)
277
+ notes.push('The repo kept changing while it was copied, so the diff hash may not describe exactly the files that were tested.');
278
+ }
279
+ stopIfAborted();
280
+ log(`Made a private copy of ${made.files} changed file(s) at ${made.head ? made.head.slice(0, 8) : 'no commit'}.`);
281
+ stageDone(log, 'copy', copyStart);
282
+ // The app folder is walked again inside the copy: a link or a missing package.json there stops the run
283
+ const appCwd = sandboxAppRoot(made.repoInCopy, cfg.appDir);
284
+ // -- 3. WHAT WAS TESTED -- //
285
+ const commit = made.head;
286
+ // -- 4. A FREE PORT -- //
287
+ const port = await pickPort();
288
+ const baseUrl = `http://127.0.0.1:${port}`;
289
+ // -- 5. A THROWAWAY DATABASE, ONLY WHEN THE APP NEEDS ONE -- //
290
+ let databaseUrl;
291
+ let ownerUrl;
292
+ if (cfg.needsDatabase) {
293
+ const databaseStart = Date.now();
294
+ // One pool container per machine and a clone per run. The app migrates and seeds itself at start (AFB_AUTO_MIGRATE), so the template
295
+ // is an empty database that every run clones; it falls back to a container per run when the pool is not available
296
+ const db = await provisionDatabase(runId, scope, log, {
297
+ runtimeRole: cfg.runtimeRole,
298
+ template: {
299
+ recipeHash: createHash('sha256')
300
+ .update(JSON.stringify([cfg.build, cfg.start, cfg.runtimeRole ?? '']))
301
+ .digest('hex')
302
+ .slice(0, 16),
303
+ migrationHash: 'self-migrating',
304
+ warm: async () => undefined
305
+ }
306
+ });
307
+ databaseUrl = db.databaseUrl;
308
+ ownerUrl = db.ownerUrl;
309
+ secrets.push(db.databaseUrl, db.ownerUrl);
310
+ learn(db.databaseUrl, db.ownerUrl);
311
+ notes.push('A throwaway database was made for this run.');
312
+ stageDone(log, 'database', databaseStart);
313
+ }
314
+ // -- ENVIRONMENT -- //
315
+ // Sealed values ({secret:NAME}) are opened from this registration's own store only; service and mail keys are for the start alone, the build never sees them
316
+ let recipeEnv = cfg.env;
317
+ let startOnly = [];
318
+ let sealedHosts = [];
319
+ let sealedReds = [];
320
+ let sealedNames = [];
321
+ let mailInbox = null;
322
+ if (cfg.registration && Object.values(cfg.env).some((v) => v.includes('{secret:'))) {
323
+ const opened = resolveSealedEnv(cfg.registration.org, cfg.registration.name, cfg.env);
324
+ recipeEnv = opened.env;
325
+ startOnly = opened.startOnly;
326
+ sealedHosts = opened.hosts;
327
+ sealedReds = opened.redactions;
328
+ sealedNames = [...opened.filled];
329
+ mailInbox = opened.mailInbox;
330
+ secrets.push(...opened.redactions);
331
+ learn(...opened.redactions);
332
+ notes.push(`Opened ${opened.filled.length} sealed value(s) (names only: ${opened.filled.join(', ')}).`);
333
+ }
334
+ // A registration whose owner approved it adopts the whole repo .env (database values are refused, not stripped); any other keeps the stricter rule
335
+ let startEnv;
336
+ let buildEnv;
337
+ let adoptedReds = [];
338
+ if (cfg.adoptEnv) {
339
+ const adopted = buildAdoptedEnv({
340
+ repo: cfg.repo,
341
+ appDir: cfg.appDir,
342
+ recipeEnv,
343
+ port,
344
+ baseUrl,
345
+ databaseUrl,
346
+ databaseOwnerUrl: ownerUrl,
347
+ sealedNames,
348
+ testHost: sealedHosts[0]
349
+ });
350
+ if (adopted.problems.length)
351
+ throw new Error(adopted.problems.join(' '));
352
+ startEnv = adopted.env;
353
+ buildEnv = adopted.buildEnv;
354
+ // The address of the preview itself is not a secret: keeping it out of the redactions keeps the run log readable
355
+ adoptedReds = adopted.redactions.filter((v) => !v.includes(baseUrl));
356
+ notes.push(...adopted.notes);
357
+ }
358
+ else {
359
+ const built = buildPreviewEnv({
360
+ repo: cfg.repo,
361
+ recipeEnv,
362
+ port,
363
+ baseUrl,
364
+ databaseUrl,
365
+ databaseOwnerUrl: ownerUrl
366
+ });
367
+ startEnv = built.env;
368
+ buildEnv = built.env;
369
+ notes.push(...built.notes);
370
+ }
371
+ buildEnv = omitKeys(buildEnv, startOnly);
372
+ const mailLog = join(logDir, 'mail-guard.jsonl');
373
+ if (mailInbox)
374
+ startEnv = withMailGuard(startEnv, { inbox: mailInbox, log: mailLog });
375
+ const redact = [
376
+ ...Object.values(startEnv).filter((v) => v.length >= 6),
377
+ ...Object.values(buildEnv).filter((v) => v.length >= 6),
378
+ ...secrets,
379
+ ...sealedReds,
380
+ ...adoptedReds
381
+ ];
382
+ secrets.push(...adoptedReds);
383
+ if (cfg.testUser) {
384
+ secrets.push(cfg.testUser.password);
385
+ redact.push(cfg.testUser.password);
386
+ }
387
+ learn(...redact);
388
+ stopIfAborted();
389
+ // -- 6. INSTALL AND BUILD -- //
390
+ const buildLogFile = join(logDir, 'build.log');
391
+ const buildLog = openSync(buildLogFile, 'a');
392
+ // The command of a stage ended without an exit code: it timed out, was killed or was stopped. That is the machine's trouble, not the code's, so a run that waited for it may try again
393
+ const stageFailed = (what, code) => stageError(what, code, Boolean(signal?.aborted), () => tailOf(buildLogFile, redact));
394
+ try {
395
+ const installStart = Date.now();
396
+ // A monorepo keeps its lock file above the app folder: the install runs where the lock file is
397
+ const installDir = installDirFor(appCwd, copyDir);
398
+ const install = installDir ? installCommand(installDir) : null;
399
+ if (!install || !installDir)
400
+ log('No lockfile, install skipped.');
401
+ if (install && installDir) {
402
+ const doInstall = async () => {
403
+ stopIfAborted();
404
+ log('Installing dependencies in the private copy.');
405
+ const code = await runToEnd(install, {
406
+ cwd: installDir,
407
+ env: buildEnv,
408
+ out: buildLog,
409
+ timeoutMs: BUILD_TIMEOUT_MS,
410
+ scope,
411
+ name: 'install process',
412
+ signal
413
+ });
414
+ if (code !== 0)
415
+ throw stageFailed('install', code);
416
+ };
417
+ // Dependencies are kept per registration by lockfile hash, node, platform, architecture and package manager. Runs that need the same set
418
+ // install once (single flight) and the others restore it; a pnpm install is never kept (its modules name the store location)
419
+ const pm = packageManagerOf(installDir);
420
+ const depsCacheRoot = cfg.registration
421
+ ? cacheDirFor(cfg.registration.org, cfg.registration.name)
422
+ : null;
423
+ const dkey = depsCacheRoot && pm && installCacheable(installDir)
424
+ ? depsKey({
425
+ lockHash: lockHash(installDir),
426
+ node: process.version,
427
+ platform: process.platform,
428
+ arch: process.arch,
429
+ pm,
430
+ pmVersion: await packageManagerVersion(pm, installDir, buildEnv)
431
+ })
432
+ : null;
433
+ if (depsCacheRoot && dkey) {
434
+ const flight = await singleFlight(`deps-${dkey.slice(0, 32)}`, async () => {
435
+ if (hasEntry(depsCacheRoot, 'deps', dkey))
436
+ return { hit: true };
437
+ await doInstall();
438
+ await publishDepsAsync(depsCacheRoot, dkey, installDir);
439
+ return { hit: false };
440
+ }, { signal });
441
+ const installedHere = flight.role === 'builder' && !flight.result.hit;
442
+ if (!installedHere) {
443
+ const restored = await restoreOrNull('dependencies', () => restoreDepsAsync(depsCacheRoot, dkey, installDir, { link: 'auto' }), log);
444
+ if (restored)
445
+ log(`Restored the kept dependencies (${restored.files} files).`);
446
+ else
447
+ await doInstall();
448
+ }
449
+ evict(depsCacheRoot, { builds: KEEP_BUILDS, deps: KEEP_DEPS, maxAgeDays: 14 });
450
+ }
451
+ else
452
+ await doInstall();
453
+ }
454
+ stageDone(log, 'install', installStart);
455
+ stopIfAborted();
456
+ const buildStart = Date.now();
457
+ if (cfg.build) {
458
+ const doBuild = async () => {
459
+ stopIfAborted();
460
+ log('Building the project.');
461
+ const code = await runToEnd(words(cfg.build), {
462
+ cwd: appCwd,
463
+ env: buildEnv,
464
+ out: buildLog,
465
+ timeoutMs: BUILD_TIMEOUT_MS,
466
+ scope,
467
+ name: 'build process',
468
+ signal
469
+ });
470
+ if (code !== 0)
471
+ throw stageFailed('build', code);
472
+ };
473
+ // A finished build is kept per registration when nothing that decides it is per run (no port, base url, database url or secret in the
474
+ // command or the environment, adopted values included). Runs that need the same build build it once and the others restore it
475
+ const buildRoot = cfg.registration
476
+ ? cacheDirFor(cfg.registration.org, cfg.registration.name)
477
+ : null;
478
+ const tree = treeId({ commit, diffHash });
479
+ const bkey = buildRoot && tree
480
+ ? buildKey({
481
+ org: cfg.registration?.org ?? '',
482
+ name: cfg.registration?.name ?? '',
483
+ treeId: tree,
484
+ surface: pinFiles(cfg.repo),
485
+ build: cfg.build,
486
+ env: buildEnv,
487
+ secretValues: secrets,
488
+ appDir: cfg.appDir ?? ''
489
+ })
490
+ : null;
491
+ if (buildRoot && bkey) {
492
+ const before = await snapshotAsync(appCwd);
493
+ const flight = await singleFlight(`build-${bkey.slice(0, 32)}`, async () => {
494
+ if (hasEntry(buildRoot, 'builds', bkey))
495
+ return { hit: true };
496
+ await doBuild();
497
+ await publishBuildAsync(buildRoot, bkey, appCwd, before);
498
+ return { hit: false };
499
+ }, { signal });
500
+ const builtHere = flight.role === 'builder' && !flight.result.hit;
501
+ if (!builtHere) {
502
+ const restored = await restoreOrNull('build', () => restoreBuildAsync(buildRoot, bkey, appCwd, { link: 'auto' }), log);
503
+ if (restored)
504
+ log(`Restored the kept build (${restored.files} files).`);
505
+ else
506
+ await doBuild();
507
+ }
508
+ }
509
+ else
510
+ await doBuild();
511
+ }
512
+ stageDone(log, 'build', buildStart);
513
+ }
514
+ finally {
515
+ closeSync(buildLog);
516
+ }
517
+ stopIfAborted();
518
+ // -- 7. START -- //
519
+ const startStageStart = Date.now();
520
+ const previewLogFile = join(logDir, 'preview.log');
521
+ const startLog = openSync(previewLogFile, 'a');
522
+ let child;
523
+ try {
524
+ child = await launch(words(cfg.start.split('{port}').join(String(port))), {
525
+ cwd: appCwd,
526
+ env: startEnv,
527
+ out: startLog,
528
+ detached: process.platform !== 'win32'
529
+ });
530
+ }
531
+ finally {
532
+ closeSync(startLog);
533
+ }
534
+ let exited = false;
535
+ child.once('exit', () => (exited = true));
536
+ if (child.pid) {
537
+ const pid = child.pid;
538
+ scope.setInfo({ previewPid: pid });
539
+ scope.add('preview process', () => killTree(pid));
540
+ }
541
+ // -- 8. WAIT UNTIL IT ANSWERS -- //
542
+ const readyUrl = baseUrl + (cfg.ready ?? '/');
543
+ const state = await waitReady(readyUrl, {
544
+ timeoutMs: READY_TIMEOUT_MS,
545
+ exited: () => exited,
546
+ signal
547
+ });
548
+ if (state === 'aborted')
549
+ throw new Error(STOPPED);
550
+ if (state !== 'ready') {
551
+ const why = state === 'exited'
552
+ ? 'The preview stopped before it answered.'
553
+ : 'The preview did not answer within 120 seconds.';
554
+ throw new Error(`${why} Last lines:\n${tailOf(previewLogFile, redact)}`);
555
+ }
556
+ log('The preview answers.');
557
+ stageDone(log, 'start-and-ready', startStageStart);
558
+ // The mail guard must have reported listening on the preview port, or mail may not have been fenced and the run does not count
559
+ if (mailInbox) {
560
+ const fence = readFence(mailLog, port);
561
+ const problem = fenceProblem(true, fence);
562
+ if (problem)
563
+ throw new Error(problem);
564
+ notes.push(describeFence(mailInbox, fence));
565
+ }
566
+ // -- 9. RESULT -- //
567
+ return {
568
+ baseUrl,
569
+ commit,
570
+ diffHash,
571
+ testUser: cfg.testUser ?? null,
572
+ secrets,
573
+ note: notes.join(' '),
574
+ extraHosts: sealedHosts,
575
+ ...(mailInbox ? { mail: { inbox: mailInbox, log: mailLog, port } } : {})
576
+ };
577
+ }
@@ -0,0 +1,110 @@
1
+ // SAVED SIGN-INS: ONE SEALED FILE PER REGISTRATION AND SITE
2
+ // A saved sign-in is the browser state (cookies and local storage) of a person signed in to one site. It is sealed with the bridge's key, bound
3
+ // to the registration and the site address (the AAD), and lives under that registration's own state folder. It is used only for the site it
4
+ // was saved for, and it never reaches a check except through the child's stdin.
5
+ // -- DEP-MODULES -- //
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { ensureDir, ORG_SLUG, stateDirFor } from './paths.js';
11
+ import { renameRetry } from './fsretry.js';
12
+ import { seal, unseal } from './store.js';
13
+ // -- CONSTANTS -- //
14
+ const NAME = /^[A-Za-z0-9_-]{1,32}$/;
15
+ const FILE = /^[0-9a-f]{16}\.enc$/;
16
+ const enc = new TextEncoder();
17
+ const dec = new TextDecoder();
18
+ // -- FUNCTIONS -- //
19
+ function check(org, name) {
20
+ if (!ORG_SLUG.test(org))
21
+ throw new Error('The organisation is not a slug.');
22
+ if (!NAME.test(name))
23
+ throw new Error('The registration name is not a plain name.');
24
+ }
25
+ const profilesDir = (org, name) => {
26
+ check(org, name);
27
+ return join(stateDirFor(org, name), 'profiles');
28
+ };
29
+ /** The file name for a site address: 16 hex characters of its hash. */
30
+ export const fileNameFor = (bindKey) => `${createHash('sha256').update(bindKey).digest('hex').slice(0, 16)}.enc`;
31
+ const aadFor = (org, name, bindKey) => `profile:${org}/${name}:${bindKey}`;
32
+ /** Saves (replaces) the sign-in of one registration and site. Returns the file name, not the path. */
33
+ export function saveProfile(org, name, bindKey, state) {
34
+ if (!bindKey)
35
+ throw new Error('A saved sign-in needs the address of the site.');
36
+ const dir = ensureDir(profilesDir(org, name));
37
+ const file = fileNameFor(bindKey);
38
+ const profile = { bindKey, savedAt: new Date().toISOString(), state };
39
+ const blob = seal(aadFor(org, name, bindKey), enc.encode(JSON.stringify(profile)));
40
+ const tmp = join(dir, `${file}.${process.pid}.tmp`);
41
+ writeFileSync(tmp, blob, { mode: 0o600 });
42
+ renameRetry(tmp, join(dir, file));
43
+ // The address is not a secret: a plain index lets a listing name the site without opening the sealed file
44
+ writeFileSync(indexPath(org, name, file), JSON.stringify({ bindKey }), { mode: 0o600 });
45
+ return file;
46
+ }
47
+ /** The saved sign-in for exactly this registration and site, or null (none, damaged, or saved for another address). */
48
+ export function loadProfile(org, name, bindKey) {
49
+ return loadProfileChecked(org, name, bindKey).profile;
50
+ }
51
+ /** The same, and says when a file is there but cannot be opened (damaged, or sealed for another address), so a run does not go signed out without saying so. */
52
+ export function loadProfileChecked(org, name, bindKey) {
53
+ const path = join(profilesDir(org, name), fileNameFor(bindKey));
54
+ if (!existsSync(path))
55
+ return { profile: null, damaged: false };
56
+ try {
57
+ const raw = unseal(aadFor(org, name, bindKey), readFileSync(path));
58
+ const p = JSON.parse(dec.decode(raw));
59
+ return p && p.bindKey === bindKey
60
+ ? { profile: p, damaged: false }
61
+ : { profile: null, damaged: true };
62
+ }
63
+ catch {
64
+ return { profile: null, damaged: true };
65
+ }
66
+ }
67
+ /** A plain sentence when every timed cookie has run out, so a run can say so before it fails on a sign-in page. */
68
+ export function expiryNote(profile) {
69
+ const cookies = profile?.state?.cookies ?? [];
70
+ const now = Date.now() / 1000;
71
+ const timed = cookies.filter((c) => typeof c.expires === 'number' && c.expires > 0);
72
+ return timed.length && timed.every((c) => c.expires < now)
73
+ ? 'The saved sign-in has expired.'
74
+ : null;
75
+ }
76
+ /** Which sites have a saved sign-in: file, address and date, never a cookie or a value. The address is read from the plain index beside each file. */
77
+ export function listProfiles(org, name) {
78
+ const dir = profilesDir(org, name);
79
+ if (!existsSync(dir))
80
+ return [];
81
+ const out = [];
82
+ for (const file of readdirSync(dir).filter((f) => FILE.test(f))) {
83
+ let bindKey = '(unknown)';
84
+ try {
85
+ bindKey = String(JSON.parse(readFileSync(indexPath(org, name, file), 'utf8'))
86
+ .bindKey ?? bindKey);
87
+ }
88
+ catch {
89
+ /* No index: the file is still listed */
90
+ }
91
+ out.push({ file, bindKey, savedAt: statSync(join(dir, file)).mtime.toISOString() });
92
+ }
93
+ return out;
94
+ }
95
+ const indexPath = (org, name, file) => join(profilesDir(org, name), file + '.site');
96
+ /** Removes one saved sign-in by its exact file name. True when a file went. */
97
+ export function removeProfile(org, name, file) {
98
+ if (!FILE.test(file))
99
+ throw new Error('That is not a saved sign-in file name.');
100
+ const dir = profilesDir(org, name);
101
+ let removed = false;
102
+ for (const f of [file, `${file}.site`]) {
103
+ const p = join(dir, f);
104
+ if (existsSync(p)) {
105
+ rmSync(p, { force: true });
106
+ removed ||= f === file;
107
+ }
108
+ }
109
+ return removed;
110
+ }