@bridge4dev/runner 0.13.1 → 0.22.1

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 (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +402 -4
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +28 -1
  35. package/dist/service-unit.d.ts +48 -1
  36. package/dist/service-unit.js +109 -4
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
package/dist/verify.js ADDED
@@ -0,0 +1,788 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { log } from './log.js';
6
+ import { maskString } from './policy.js';
7
+ import { evaluateRecipeCommand } from './policy.js';
8
+ import { recipeFingerprint } from './recipe.js';
9
+ import { StringDecoder } from 'node:string_decoder';
10
+ import { stateDir } from './paths.js';
11
+ import { RECIPE_DEFAULT_TIMEOUT_SEC, RECIPE_MAX_TIMEOUT_SEC, RECIPE_STEP_NAMES, } from './recipe-schema.js';
12
+ /** Free space a run refuses to start below. Builds are not small. */
13
+ export const VERIFY_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024;
14
+ /** How much of a build log is worth keeping on disk. */
15
+ const LOG_CAP_BYTES = 2 * 1024 * 1024;
16
+ /** How much of it travels in one `verify_status` answer. */
17
+ const LOG_CHUNK_BYTES = 64 * 1024;
18
+ /** The tail that goes into the report frame and, from there, into the row. */
19
+ const LOG_TAIL_BYTES = 8 * 1024;
20
+ /** SIGTERM, then this long, then SIGKILL. */
21
+ const KILL_GRACE_MS = 10_000;
22
+ /** After a step's process exits, how long to wait for its output to drain. */
23
+ const STDIO_GRACE_MS = 2_000;
24
+ /** A health probe answers in seconds or it is not answering. */
25
+ const HEALTH_TIMEOUT_MS = 8_000;
26
+ const HEALTH_BODY_CAP = 64 * 1024;
27
+ /**
28
+ * Everything a build is allowed to see of the runner's environment.
29
+ *
30
+ * An allowlist for the same reason the Claude adapter has one (QA-96 F12): this
31
+ * process holds `dbr_` runner tokens and whatever else the machine's owner
32
+ * exported, and a build script is a program somebody else wrote.
33
+ */
34
+ const ENV_ALLOWLIST = [
35
+ 'PATH',
36
+ 'HOME',
37
+ 'USER',
38
+ 'LOGNAME',
39
+ 'SHELL',
40
+ 'LANG',
41
+ 'LANGUAGE',
42
+ 'LC_ALL',
43
+ 'LC_CTYPE',
44
+ 'TZ',
45
+ 'TMPDIR',
46
+ 'XDG_RUNTIME_DIR',
47
+ 'XDG_DATA_HOME',
48
+ 'XDG_CONFIG_HOME',
49
+ 'XDG_CACHE_HOME',
50
+ 'HTTP_PROXY',
51
+ 'HTTPS_PROXY',
52
+ 'NO_PROXY',
53
+ 'http_proxy',
54
+ 'https_proxy',
55
+ 'no_proxy',
56
+ 'SSL_CERT_FILE',
57
+ 'SSL_CERT_DIR',
58
+ 'NODE_EXTRA_CA_CERTS',
59
+ 'DOCKER_HOST',
60
+ ];
61
+ function buildEnv(extra) {
62
+ const env = {};
63
+ for (const key of ENV_ALLOWLIST) {
64
+ const value = process.env[key];
65
+ if (value !== undefined)
66
+ env[key] = value;
67
+ }
68
+ // `TERM=dumb` and `CI=1` are how build tools are told nobody is watching:
69
+ // without them a progress spinner writes escape codes into the log and an
70
+ // interactive prompt waits for a keystroke that will never come.
71
+ env['TERM'] = 'dumb';
72
+ env['CI'] = '1';
73
+ env['DEVBRIDGE_VERIFY'] = '1';
74
+ for (const [key, value] of Object.entries(extra ?? {})) {
75
+ // Never let a recipe put back what the allowlist took out.
76
+ if (key === 'PATH' ||
77
+ key === 'HOME' ||
78
+ key.startsWith('LD_') ||
79
+ key.startsWith('NODE_OPTIONS')) {
80
+ continue;
81
+ }
82
+ env[key] = value;
83
+ }
84
+ return env;
85
+ }
86
+ const execFileAsync = promisify(execFile);
87
+ /**
88
+ * The sha the commands are about to run against.
89
+ *
90
+ * Read HERE and not sent by the API, because only this side knows what is in
91
+ * the directory at the moment the build starts. Without it every run stored
92
+ * `commitSha: null`, and «current», «running now» and the `Verified:` trailer
93
+ * — all three of which compare a run's sha with the branch tip — could never
94
+ * be true. The evidence table would have been decorative.
95
+ */
96
+ async function headShaOf(cwd) {
97
+ try {
98
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
99
+ cwd,
100
+ timeout: 10_000,
101
+ });
102
+ const sha = stdout.trim();
103
+ return /^[0-9a-f]{7,64}$/i.test(sha) ? sha : null;
104
+ }
105
+ catch {
106
+ // Not a git directory, or git is unavailable. A run without a sha still
107
+ // runs — it simply cannot support the words that compare shas.
108
+ return null;
109
+ }
110
+ }
111
+ /**
112
+ * Was the tree dirty when the commands started?
113
+ *
114
+ * Read here for the same reason as the sha, and it matters just as much: it is
115
+ * half of «current» and the whole of the clean-tree condition on the
116
+ * `Verified:` trailer. Neither side used to set it — the API never sent it and
117
+ * the runner only read what it was sent — so both guards were permanently
118
+ * `false`, which is to say permanently off (QA-108).
119
+ *
120
+ * `null` on error, which callers must not read as «clean».
121
+ */
122
+ async function isDirty(cwd) {
123
+ try {
124
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
125
+ cwd,
126
+ timeout: 15_000,
127
+ maxBuffer: 4 * 1024 * 1024,
128
+ });
129
+ return stdout.trim().length > 0;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ /** How many finished build logs are worth keeping on somebody's machine. */
136
+ const KEEP_VERIFY_LOGS = 20;
137
+ /**
138
+ * Delete all but the newest build logs.
139
+ *
140
+ * A twenty-minute build writes megabytes, one file per run, and nothing ever
141
+ * removed them: the directory grew without bound on the user's own disk. This
142
+ * is not a hypothetical on this project — 2026-07-13 is the day a root disk
143
+ * filled with build cache and took `sshd` and `journald` with it, which is also
144
+ * why `start` refuses to begin without free space. Pruning at start, not at
145
+ * finish: a crashed run must not be the reason its own log survives forever,
146
+ * and the newest ones are the only ones anybody reads (session 15).
147
+ */
148
+ function pruneVerifyLogs() {
149
+ try {
150
+ const dir = verifyDir();
151
+ const entries = fs
152
+ .readdirSync(dir)
153
+ .filter((name) => name.endsWith('.log'))
154
+ .map((name) => {
155
+ const full = path.join(dir, name);
156
+ return { full, mtime: fs.statSync(full).mtimeMs };
157
+ })
158
+ .sort((a, b) => b.mtime - a.mtime);
159
+ for (const stale of entries.slice(KEEP_VERIFY_LOGS)) {
160
+ fs.rmSync(stale.full, { force: true });
161
+ }
162
+ }
163
+ catch {
164
+ // Housekeeping must never be the reason a build refuses to start.
165
+ }
166
+ }
167
+ /**
168
+ * How many bytes of `buffer` end on a complete UTF-8 character.
169
+ *
170
+ * UTF-8 is self-synchronising: a lead byte announces its length, and every
171
+ * continuation byte is `10xxxxxx`. So the only work is to walk back from the
172
+ * end over at most three bytes and drop a sequence that is not finished yet.
173
+ */
174
+ export function completeUtf8Length(buffer) {
175
+ for (let back = 1; back <= 3 && back <= buffer.length; back += 1) {
176
+ const byte = buffer[buffer.length - back] ?? 0;
177
+ if ((byte & 0b1100_0000) === 0b1000_0000)
178
+ continue; // continuation — keep walking
179
+ const needed = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
180
+ return needed > back ? buffer.length - back : buffer.length;
181
+ }
182
+ return buffer.length;
183
+ }
184
+ /** Offset of the first byte that STARTS a character — for a slice cut anywhere. */
185
+ export function firstUtf8Start(buffer) {
186
+ let start = 0;
187
+ while (start < buffer.length && start < 3) {
188
+ if (((buffer[start] ?? 0) & 0b1100_0000) !== 0b1000_0000)
189
+ break;
190
+ start += 1;
191
+ }
192
+ return start;
193
+ }
194
+ function verifyDir() {
195
+ return path.join(stateDir(), 'verify');
196
+ }
197
+ /**
198
+ * Free bytes on the filesystem the build would write to.
199
+ *
200
+ * `null` when the platform cannot answer — which is treated as «go ahead»
201
+ * rather than «refuse», because refusing every run on a system without statfs
202
+ * would be a worse failure than the one this guards against.
203
+ */
204
+ export function freeBytesFor(target) {
205
+ try {
206
+ const stat = fs.statfsSync(target);
207
+ return Number(stat.bavail) * Number(stat.bsize);
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ export class VerifyRunner {
214
+ opts;
215
+ active = null;
216
+ /** Finished runs stay readable until the next one starts. */
217
+ last = null;
218
+ constructor(opts) {
219
+ this.opts = opts;
220
+ }
221
+ get enabled() {
222
+ return this.opts.enabled;
223
+ }
224
+ get busy() {
225
+ return this.active !== null && this.active.status === 'RUNNING';
226
+ }
227
+ /** The run currently holding the machine, if any — for a truthful refusal. */
228
+ get activeRunId() {
229
+ return this.busy ? (this.active?.runId ?? null) : null;
230
+ }
231
+ /** What kind of run is holding it — `preview_stop` only owns its own. */
232
+ get activeTarget() {
233
+ return this.busy ? (this.active?.target ?? null) : null;
234
+ }
235
+ start(input) {
236
+ if (!this.opts.enabled) {
237
+ return {
238
+ started: false,
239
+ error: 'Verification is switched off on this machine (`[verify] enabled = false`)',
240
+ };
241
+ }
242
+ if (this.busy) {
243
+ return {
244
+ started: false,
245
+ error: `A verification is already running on this machine (${this.active?.runId})`,
246
+ };
247
+ }
248
+ // The approval is of specific commands. A recipe that no longer hashes to
249
+ // what was approved is not the approved recipe.
250
+ const actual = recipeFingerprint(input.recipe);
251
+ if (actual !== input.recipeSha) {
252
+ return {
253
+ started: false,
254
+ error: 'The approved recipe changed — approve it again before verifying',
255
+ };
256
+ }
257
+ if (!fs.existsSync(input.cwd)) {
258
+ return { started: false, error: `The working directory ${input.cwd} does not exist` };
259
+ }
260
+ const commands = this.resolveCommands(input);
261
+ if ('error' in commands)
262
+ return { started: false, error: commands.error };
263
+ if (commands.list.length === 0) {
264
+ return {
265
+ started: false,
266
+ error: input.preview
267
+ ? 'This project has no `preview` command in its recipe'
268
+ : 'This project has no build steps in its recipe — there is nothing to run',
269
+ };
270
+ }
271
+ const free = freeBytesFor(input.cwd);
272
+ if (free !== null && free < VERIFY_MIN_FREE_BYTES) {
273
+ return {
274
+ started: false,
275
+ error: `Only ${Math.round(free / 1024 / 1024)} MB free on this disk — a build needs at least ${Math.round(VERIFY_MIN_FREE_BYTES / 1024 / 1024)} MB. Free some space and try again.`,
276
+ };
277
+ }
278
+ fs.mkdirSync(verifyDir(), { recursive: true, mode: 0o700 });
279
+ pruneVerifyLogs();
280
+ const logPath = path.join(verifyDir(), `${sanitizeRunId(input.runId)}.log`);
281
+ try {
282
+ fs.writeFileSync(logPath, '', { mode: 0o600 });
283
+ }
284
+ catch (error) {
285
+ return { started: false, error: `Could not open a log file: ${String(error)}` };
286
+ }
287
+ const run = {
288
+ runId: input.runId,
289
+ target: input.target,
290
+ recipe: input.recipe,
291
+ recipeSha: input.recipeSha,
292
+ cwd: input.cwd,
293
+ steps: commands.list.map((entry) => entry.name),
294
+ completed: [],
295
+ currentStep: null,
296
+ failedStep: null,
297
+ exitCode: null,
298
+ status: 'RUNNING',
299
+ startedAt: new Date(this.now()).toISOString(),
300
+ finishedAt: null,
301
+ logPath,
302
+ logBytes: 0,
303
+ child: null,
304
+ cancelled: false,
305
+ branch: input.branch,
306
+ commitSha: input.commitSha,
307
+ dirty: input.dirty,
308
+ health: null,
309
+ previewUrl: input.preview ? (input.recipe.preview?.url ?? null) : null,
310
+ };
311
+ this.active = run;
312
+ this.last = run;
313
+ void this.execute(run, commands.list);
314
+ return { started: true };
315
+ }
316
+ status(runId, offset = 0) {
317
+ const run = this.find(runId);
318
+ if (!run)
319
+ return null;
320
+ const from = Math.max(0, Math.trunc(offset));
321
+ let chunk = '';
322
+ let next;
323
+ try {
324
+ const size = fs.statSync(run.logPath).size;
325
+ if (from < size) {
326
+ const length = Math.min(size - from, LOG_CHUNK_BYTES);
327
+ const fd = fs.openSync(run.logPath, 'r');
328
+ let read = length;
329
+ try {
330
+ const buffer = Buffer.alloc(length);
331
+ fs.readSync(fd, buffer, 0, length, from);
332
+ // Back the window off to the last COMPLETE character and let the
333
+ // next poll start there. Decoding an arbitrary byte window put a
334
+ // U+FFFD at every page boundary, in text the dashboard then
335
+ // concatenates (session 15).
336
+ read = completeUtf8Length(buffer);
337
+ chunk = buffer.subarray(0, read).toString('utf8');
338
+ }
339
+ finally {
340
+ fs.closeSync(fd);
341
+ }
342
+ // `read` can be 0 only for a lone incomplete character at the very end
343
+ // of the file; advancing by nothing is right — the rest arrives next
344
+ // time — but never leave `next` behind `from`.
345
+ next = from + Math.max(read, 0);
346
+ }
347
+ else {
348
+ next = size;
349
+ }
350
+ return {
351
+ runId: run.runId,
352
+ status: run.status,
353
+ target: run.target,
354
+ currentStep: run.currentStep,
355
+ completedSteps: [...run.completed],
356
+ failedStep: run.failedStep,
357
+ exitCode: run.exitCode,
358
+ offset: next,
359
+ chunk,
360
+ hasMore: next < size,
361
+ startedAt: run.startedAt,
362
+ finishedAt: run.finishedAt,
363
+ health: run.health,
364
+ previewUrl: run.previewUrl,
365
+ };
366
+ }
367
+ catch {
368
+ return {
369
+ runId: run.runId,
370
+ status: run.status,
371
+ target: run.target,
372
+ currentStep: run.currentStep,
373
+ completedSteps: [...run.completed],
374
+ failedStep: run.failedStep,
375
+ exitCode: run.exitCode,
376
+ offset: from,
377
+ chunk: '',
378
+ hasMore: false,
379
+ startedAt: run.startedAt,
380
+ finishedAt: run.finishedAt,
381
+ health: run.health,
382
+ previewUrl: run.previewUrl,
383
+ };
384
+ }
385
+ }
386
+ cancel(runId) {
387
+ const run = this.active;
388
+ if (!run || run.runId !== runId)
389
+ return { cancelled: false, error: 'No such run is active' };
390
+ if (run.status !== 'RUNNING')
391
+ return { cancelled: false, error: 'That run has already finished' };
392
+ run.cancelled = true;
393
+ this.killGroup(run);
394
+ return { cancelled: true };
395
+ }
396
+ /**
397
+ * Stop whatever is running because the daemon is going away.
398
+ *
399
+ * The run is NOT reported as passed or failed — it is simply abandoned, and
400
+ * the API turns its still-RUNNING row into `LOST`. A verdict nobody observed
401
+ * is not a verdict.
402
+ */
403
+ shutdown() {
404
+ if (this.active?.status === 'RUNNING') {
405
+ this.active.cancelled = true;
406
+ this.killGroup(this.active);
407
+ }
408
+ }
409
+ now() {
410
+ return this.opts.now ? this.opts.now() : Date.now();
411
+ }
412
+ find(runId) {
413
+ if (this.active?.runId === runId)
414
+ return this.active;
415
+ if (this.last?.runId === runId)
416
+ return this.last;
417
+ return null;
418
+ }
419
+ /**
420
+ * The commands this run will execute, each one already past layer 1.
421
+ *
422
+ * Resolved BEFORE anything starts, so a recipe whose `deploy` step is refused
423
+ * fails immediately instead of half-way through a release.
424
+ */
425
+ resolveCommands(input) {
426
+ const list = [];
427
+ if (input.preview) {
428
+ const preview = input.recipe.preview;
429
+ if (!preview?.run)
430
+ return { list: [] };
431
+ list.push({
432
+ name: 'preview',
433
+ step: {
434
+ run: preview.run,
435
+ ...(preview.timeoutSec !== undefined ? { timeoutSec: preview.timeoutSec } : {}),
436
+ },
437
+ });
438
+ }
439
+ else {
440
+ const wanted = new Set(input.steps);
441
+ for (const name of RECIPE_STEP_NAMES) {
442
+ if (!wanted.has(name))
443
+ continue;
444
+ const step = input.recipe.steps?.[name];
445
+ if (step?.run)
446
+ list.push({ name, step });
447
+ }
448
+ }
449
+ for (const entry of list) {
450
+ const decision = evaluateRecipeCommand(entry.step.run);
451
+ if (!decision.allowed) {
452
+ return {
453
+ error: `The \`${entry.name}\` command is refused by runner policy: ${decision.reason}`,
454
+ };
455
+ }
456
+ }
457
+ return { list };
458
+ }
459
+ async execute(run, commands) {
460
+ const started = this.now();
461
+ // What is in the directory RIGHT NOW, before anything runs. The caller may
462
+ // have supplied a sha, but only this side can see what is actually there.
463
+ run.commitSha = (await headShaOf(run.cwd)) ?? run.commitSha;
464
+ // A dirty tree cannot be «verified at a sha»: what ran is not what the sha
465
+ // describes. Unknown counts as dirty — the safe direction is the one that
466
+ // withholds a claim rather than the one that makes it.
467
+ run.dirty = (await isDirty(run.cwd)) ?? true;
468
+ this.append(run, `# DevBridge verification ${run.runId} (${run.target})\n`);
469
+ this.append(run, `# ${run.cwd}${run.commitSha ? ` @ ${run.commitSha.slice(0, 7)}` : ''}\n`);
470
+ try {
471
+ for (const { name, step } of commands) {
472
+ if (run.cancelled)
473
+ break;
474
+ run.currentStep = name;
475
+ this.append(run, `\n$ [${name}] ${step.run}\n`);
476
+ const outcome = await this.runStep(run, step);
477
+ run.exitCode = outcome.exitCode;
478
+ if (!outcome.ok) {
479
+ run.failedStep = name;
480
+ this.append(run, outcome.timedOut
481
+ ? `\n# [${name}] timed out after ${Math.round(outcome.durationMs / 1000)}s\n`
482
+ : `\n# [${name}] exited with ${outcome.exitCode}\n`);
483
+ break;
484
+ }
485
+ run.completed.push(name);
486
+ }
487
+ // The health probe is not a step: it answers «is the thing we just built
488
+ // actually the thing that is running», which no exit code can.
489
+ if (!run.cancelled && run.failedStep === null && run.recipe.health) {
490
+ run.health = await probeHealth(run.recipe.health);
491
+ this.append(run, `\n# health ${run.recipe.health.url} → ${run.health.status ?? 'no answer'}` +
492
+ `${run.health.sha ? ` (sha ${run.health.sha})` : ''}\n`);
493
+ }
494
+ }
495
+ catch (error) {
496
+ run.failedStep = run.currentStep;
497
+ this.append(run, `\n# runner error: ${maskString(String(error)).slice(0, 500)}\n`);
498
+ }
499
+ run.currentStep = null;
500
+ run.status = run.cancelled ? 'CANCELLED' : run.failedStep === null ? 'PASSED' : 'FAILED';
501
+ run.finishedAt = new Date(this.now()).toISOString();
502
+ const durationMs = this.now() - started;
503
+ if (this.active === run)
504
+ this.active = null;
505
+ const report = {
506
+ runId: run.runId,
507
+ target: run.target,
508
+ status: run.status,
509
+ recipeSha: run.recipeSha,
510
+ branch: run.branch,
511
+ commitSha: run.commitSha,
512
+ dirty: run.dirty,
513
+ steps: run.steps,
514
+ completedSteps: [...run.completed],
515
+ failedStep: run.failedStep,
516
+ exitCode: run.exitCode,
517
+ durationMs,
518
+ logTail: this.tail(run),
519
+ health: run.health,
520
+ previewUrl: run.previewUrl,
521
+ startedAt: run.startedAt,
522
+ finishedAt: run.finishedAt,
523
+ };
524
+ try {
525
+ this.opts.onReport(report);
526
+ }
527
+ catch (error) {
528
+ log.warn('verify: could not hand over the report', { error: String(error) });
529
+ }
530
+ }
531
+ runStep(run, step) {
532
+ const timeoutSec = Math.min(step.timeoutSec ?? RECIPE_DEFAULT_TIMEOUT_SEC, RECIPE_MAX_TIMEOUT_SEC);
533
+ const startedAt = this.now();
534
+ return new Promise((resolve) => {
535
+ let settled = false;
536
+ let timedOut = false;
537
+ const finish = (result) => {
538
+ if (settled)
539
+ return;
540
+ settled = true;
541
+ clearTimeout(timer);
542
+ run.child = null;
543
+ resolve({
544
+ step: run.currentStep ?? '',
545
+ durationMs: this.now() - startedAt,
546
+ ...result,
547
+ ...(timedOut ? { timedOut: true } : {}),
548
+ });
549
+ };
550
+ let child;
551
+ try {
552
+ // `detached` so the whole process tree gets the signal: a build script
553
+ // is a shell that spawns compilers, and killing only the shell leaves
554
+ // them running with the disk and the CPU.
555
+ child = spawn('/bin/sh', ['-c', step.run], {
556
+ cwd: run.cwd,
557
+ env: buildEnv(step.env),
558
+ detached: true,
559
+ stdio: ['ignore', 'pipe', 'pipe'],
560
+ });
561
+ }
562
+ catch (error) {
563
+ this.append(run, `# could not start: ${maskString(String(error)).slice(0, 300)}\n`);
564
+ finish({ ok: false, exitCode: null });
565
+ return;
566
+ }
567
+ run.child = child;
568
+ // A StringDecoder per stream, not `buffer.toString('utf8')` per chunk.
569
+ //
570
+ // A pipe read ends wherever the kernel filled the buffer, which is
571
+ // regularly mid-character: decoding each chunk on its own replaced every
572
+ // straddling character with U+FFFD, PERMANENTLY, because the mangled
573
+ // text is what gets written to the log. This project's own tooling
574
+ // prints Russian throughout, so it is the common case rather than an
575
+ // exotic one (session 15). A decoder holds the incomplete bytes until
576
+ // the next chunk completes them.
577
+ const outDecoder = new StringDecoder('utf8');
578
+ const errDecoder = new StringDecoder('utf8');
579
+ child.stdout?.on('data', (buffer) => this.append(run, outDecoder.write(buffer)));
580
+ child.stderr?.on('data', (buffer) => this.append(run, errDecoder.write(buffer)));
581
+ const timer = setTimeout(() => {
582
+ timedOut = true;
583
+ this.killGroup(run);
584
+ }, timeoutSec * 1000);
585
+ timer.unref();
586
+ child.on('error', (error) => {
587
+ this.append(run, `# ${maskString(String(error)).slice(0, 300)}\n`);
588
+ finish({ ok: false, exitCode: null });
589
+ });
590
+ // `exit` and not `close`: a step that backgrounds a daemon (`… &`, and
591
+ // every `docker compose up -d` that leaves a log follower behind) hands
592
+ // the inherited stdout to a child that never ends, and `close` waits for
593
+ // EOF on it. That wedges the single verify slot on the whole machine
594
+ // until the step timeout. `close` still gets a short grace afterwards so
595
+ // the last lines of output are not lost.
596
+ let exited = false;
597
+ child.on('exit', (code, signal) => {
598
+ exited = true;
599
+ const settle = () => finish({ ok: !timedOut && !run.cancelled && code === 0 && !signal, exitCode: code });
600
+ const grace = setTimeout(settle, STDIO_GRACE_MS);
601
+ grace.unref();
602
+ child.once('close', () => {
603
+ clearTimeout(grace);
604
+ settle();
605
+ });
606
+ });
607
+ child.on('close', (code, signal) => {
608
+ if (exited)
609
+ return;
610
+ finish({ ok: !timedOut && !run.cancelled && code === 0 && !signal, exitCode: code });
611
+ });
612
+ });
613
+ }
614
+ /** SIGTERM to the whole group, SIGKILL to whatever is still there after. */
615
+ killGroup(run) {
616
+ const child = run.child;
617
+ if (!child?.pid)
618
+ return;
619
+ const pid = child.pid;
620
+ const signal = (name) => {
621
+ try {
622
+ process.kill(-pid, name);
623
+ }
624
+ catch {
625
+ try {
626
+ child.kill(name);
627
+ }
628
+ catch {
629
+ /* already gone */
630
+ }
631
+ }
632
+ };
633
+ signal('SIGTERM');
634
+ const hard = setTimeout(() => signal('SIGKILL'), KILL_GRACE_MS);
635
+ hard.unref();
636
+ child.once('close', () => clearTimeout(hard));
637
+ }
638
+ /**
639
+ * Append to the log, masked, and stop at the cap.
640
+ *
641
+ * Masking happens per write rather than at the end, because the end may never
642
+ * come — a run the user cancels still leaves its log readable.
643
+ */
644
+ append(run, text) {
645
+ if (run.logBytes >= LOG_CAP_BYTES)
646
+ return;
647
+ const masked = maskString(text);
648
+ const room = LOG_CAP_BYTES - run.logBytes;
649
+ const slice = Buffer.byteLength(masked) > room
650
+ ? `${masked.slice(0, room)}\n…[log truncated at ${Math.round(LOG_CAP_BYTES / 1024)} KB]\n`
651
+ : masked;
652
+ try {
653
+ fs.appendFileSync(run.logPath, slice);
654
+ run.logBytes += Buffer.byteLength(slice);
655
+ }
656
+ catch {
657
+ // A log we cannot write must not fail the build it is describing.
658
+ run.logBytes = LOG_CAP_BYTES;
659
+ }
660
+ }
661
+ tail(run) {
662
+ try {
663
+ const size = fs.statSync(run.logPath).size;
664
+ const from = Math.max(0, size - LOG_TAIL_BYTES);
665
+ const fd = fs.openSync(run.logPath, 'r');
666
+ try {
667
+ const buffer = Buffer.alloc(size - from);
668
+ fs.readSync(fd, buffer, 0, size - from, from);
669
+ // The START of the tail is the arbitrary cut here, so skip any
670
+ // continuation bytes the slice began in the middle of. This text is
671
+ // stored in `logTail` and read long after the run.
672
+ return buffer.subarray(firstUtf8Start(buffer)).toString('utf8');
673
+ }
674
+ finally {
675
+ fs.closeSync(fd);
676
+ }
677
+ }
678
+ catch {
679
+ return '';
680
+ }
681
+ }
682
+ }
683
+ /**
684
+ * One command, run once, with no log and no report — the preview `stop`.
685
+ *
686
+ * Same working directory and same environment allowlist as a step; it just has
687
+ * nowhere to report to, because stopping a preview is bookkeeping rather than a
688
+ * verdict. Layer 1 has already been consulted by the caller (this is the one
689
+ * command allowed to take a compose project down, and only its own).
690
+ */
691
+ export function runOneOffCommand(command, cwd, timeoutSec = 120) {
692
+ return new Promise((resolve) => {
693
+ let output = '';
694
+ let settled = false;
695
+ const finish = (ok, exitCode) => {
696
+ if (settled)
697
+ return;
698
+ settled = true;
699
+ clearTimeout(timer);
700
+ resolve({ ok, exitCode, output: maskString(output).slice(0, 4_000) });
701
+ };
702
+ let child;
703
+ try {
704
+ child = spawn('/bin/sh', ['-c', command], {
705
+ cwd,
706
+ env: buildEnv(undefined),
707
+ detached: true,
708
+ stdio: ['ignore', 'pipe', 'pipe'],
709
+ });
710
+ }
711
+ catch (error) {
712
+ resolve({ ok: false, exitCode: null, output: maskString(String(error)).slice(0, 500) });
713
+ return;
714
+ }
715
+ const onData = (buffer) => {
716
+ if (output.length < 16_000)
717
+ output += buffer.toString('utf8');
718
+ };
719
+ child.stdout?.on('data', onData);
720
+ child.stderr?.on('data', onData);
721
+ const timer = setTimeout(() => {
722
+ try {
723
+ if (child.pid)
724
+ process.kill(-child.pid, 'SIGKILL');
725
+ }
726
+ catch {
727
+ child.kill('SIGKILL');
728
+ }
729
+ }, timeoutSec * 1000);
730
+ timer.unref();
731
+ child.on('error', () => finish(false, null));
732
+ child.on('close', (code) => finish(code === 0, code));
733
+ });
734
+ }
735
+ /** A run id becomes a file name — keep it to something that cannot escape. */
736
+ function sanitizeRunId(runId) {
737
+ return runId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'run';
738
+ }
739
+ /**
740
+ * Ask the running application what it is.
741
+ *
742
+ * Two facts, and they are different: that it answered at all, and which build
743
+ * it says it is. Only the second can support «running the version we verified»,
744
+ * and a project that does not expose a sha simply never gets that sentence.
745
+ */
746
+ export async function probeHealth(health) {
747
+ try {
748
+ const response = await fetch(health.url, {
749
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
750
+ redirect: 'manual',
751
+ headers: { accept: 'application/json' },
752
+ });
753
+ const ok = response.status >= 200 && response.status < 300;
754
+ let sha = null;
755
+ if (health.shaPath) {
756
+ const text = (await response.text()).slice(0, HEALTH_BODY_CAP);
757
+ try {
758
+ sha = extractSha(JSON.parse(text), health.shaPath);
759
+ }
760
+ catch {
761
+ sha = null;
762
+ }
763
+ }
764
+ return { ok, status: response.status, sha };
765
+ }
766
+ catch (error) {
767
+ return {
768
+ ok: false,
769
+ status: null,
770
+ sha: null,
771
+ detail: maskString(String(error instanceof Error ? error.message : error)).slice(0, 300),
772
+ };
773
+ }
774
+ }
775
+ /** `data.build.sha` → the string at that path, if it is a plausible sha. */
776
+ export function extractSha(body, dottedPath) {
777
+ let cursor = body;
778
+ for (const key of dottedPath.split('.')) {
779
+ if (!cursor || typeof cursor !== 'object')
780
+ return null;
781
+ cursor = cursor[key];
782
+ }
783
+ if (typeof cursor !== 'string')
784
+ return null;
785
+ const value = cursor.trim();
786
+ return /^[0-9a-f]{7,64}$/i.test(value) ? value : null;
787
+ }
788
+ //# sourceMappingURL=verify.js.map