@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.
- package/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +402 -4
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +7 -0
- package/dist/self-update.js +28 -1
- package/dist/service-unit.d.ts +48 -1
- package/dist/service-unit.js +109 -4
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|