@bridge4dev/runner 0.13.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/auth-relay.d.ts +33 -3
- package/dist/auth-relay.js +199 -16
- 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/environment.d.ts +171 -0
- package/dist/environment.js +409 -0
- package/dist/git.d.ts +81 -0
- package/dist/git.js +301 -15
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +715 -8
- package/dist/paths.d.ts +35 -0
- package/dist/paths.js +45 -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 +21 -0
- package/dist/self-update.js +73 -1
- package/dist/service-unit.d.ts +61 -2
- package/dist/service-unit.js +150 -14
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1045 -57
- 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/recipe.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { PROJECT_RECIPE_PATH, canonicalRecipeJson, parseProjectRecipe, recipeSteps, } from './recipe-schema.js';
|
|
5
|
+
import { evaluateRecipeCommand, maskString } from './policy.js';
|
|
6
|
+
/**
|
|
7
|
+
* `.devbridge/project.json` — read, never executed (session 14).
|
|
8
|
+
*
|
|
9
|
+
* The file in a repository is a PROPOSAL. Nothing in this module runs anything:
|
|
10
|
+
* it reads the file, says whether it parses, and hands back a fingerprint. The
|
|
11
|
+
* executable copy lives on the DevBridge side and gets there only when a human
|
|
12
|
+
* with manager rights approves it — because an agent can edit files in its own
|
|
13
|
+
* branch, and «the runner does what the file says» would be arbitrary command
|
|
14
|
+
* execution by diff.
|
|
15
|
+
*/
|
|
16
|
+
/** A recipe file bigger than this is not a recipe. */
|
|
17
|
+
const RECIPE_MAX_BYTES = 64 * 1024;
|
|
18
|
+
/**
|
|
19
|
+
* A stable fingerprint of a recipe.
|
|
20
|
+
*
|
|
21
|
+
* `verify_start` carries it and the runner refuses a run whose recipe does not
|
|
22
|
+
* match: an approval is an approval of specific commands, and «the approved
|
|
23
|
+
* recipe» has to mean the same bytes on both sides.
|
|
24
|
+
*/
|
|
25
|
+
export function recipeFingerprint(recipe) {
|
|
26
|
+
return crypto.createHash('sha256').update(canonicalRecipeJson(recipe)).digest('hex').slice(0, 32);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read the proposal out of a checkout.
|
|
30
|
+
*
|
|
31
|
+
* `lstat` and not `stat`: a symlink at `.devbridge/project.json` pointing at
|
|
32
|
+
* `~/.aws/credentials` would otherwise be read and shown verbatim on the
|
|
33
|
+
* approval screen.
|
|
34
|
+
*
|
|
35
|
+
* The DIRECTORY is checked the same way, and separately (session 15): `lstat`
|
|
36
|
+
* on the file follows every symlink above it, so `.devbridge` itself being a
|
|
37
|
+
* link to somewhere else in the filesystem walked straight past the guard while
|
|
38
|
+
* the guard's own comment said it did not.
|
|
39
|
+
*/
|
|
40
|
+
export function readRecipeProposal(root) {
|
|
41
|
+
const file = path.join(root, PROJECT_RECIPE_PATH);
|
|
42
|
+
const empty = {
|
|
43
|
+
path: file,
|
|
44
|
+
present: false,
|
|
45
|
+
raw: null,
|
|
46
|
+
recipe: null,
|
|
47
|
+
error: null,
|
|
48
|
+
sha: null,
|
|
49
|
+
steps: [],
|
|
50
|
+
commands: [],
|
|
51
|
+
refusals: [],
|
|
52
|
+
};
|
|
53
|
+
// Everything read here is shown to a human and hashed into an approval, so
|
|
54
|
+
// it has to come from inside the checkout — not from wherever a link points.
|
|
55
|
+
const insideCheckout = (() => {
|
|
56
|
+
try {
|
|
57
|
+
const realRoot = fs.realpathSync(root);
|
|
58
|
+
const realDir = fs.realpathSync(path.dirname(file));
|
|
59
|
+
const rel = path.relative(realRoot, realDir);
|
|
60
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// No `.devbridge` directory at all — the ordinary case, and the read
|
|
64
|
+
// below reports it as «no recipe» on its own.
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
if (!insideCheckout) {
|
|
69
|
+
return {
|
|
70
|
+
...empty,
|
|
71
|
+
present: true,
|
|
72
|
+
error: `${PROJECT_RECIPE_PATH} points outside the repository`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
const stat = fs.lstatSync(file);
|
|
78
|
+
if (!stat.isFile()) {
|
|
79
|
+
return { ...empty, present: true, error: `${PROJECT_RECIPE_PATH} is not a regular file` };
|
|
80
|
+
}
|
|
81
|
+
if (stat.size > RECIPE_MAX_BYTES) {
|
|
82
|
+
return {
|
|
83
|
+
...empty,
|
|
84
|
+
present: true,
|
|
85
|
+
error: `${PROJECT_RECIPE_PATH} is larger than ${Math.round(RECIPE_MAX_BYTES / 1024)} KB`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return empty;
|
|
92
|
+
}
|
|
93
|
+
const masked = maskString(raw).slice(0, RECIPE_MAX_BYTES);
|
|
94
|
+
let parsedJson;
|
|
95
|
+
try {
|
|
96
|
+
// JSONC in the plan's example is illustrative; the file itself is JSON.
|
|
97
|
+
// Comments are stripped rather than refused — a recipe people hand-write is
|
|
98
|
+
// exactly the kind of file that grows a `// why` line.
|
|
99
|
+
parsedJson = JSON.parse(stripJsonComments(raw));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
...empty,
|
|
104
|
+
present: true,
|
|
105
|
+
raw: masked,
|
|
106
|
+
error: `${PROJECT_RECIPE_PATH} is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseProjectRecipe(parsedJson);
|
|
110
|
+
if (!parsed.ok) {
|
|
111
|
+
return { ...empty, present: true, raw: masked, error: parsed.error };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
path: file,
|
|
115
|
+
present: true,
|
|
116
|
+
raw: masked,
|
|
117
|
+
recipe: parsed.recipe,
|
|
118
|
+
error: null,
|
|
119
|
+
sha: recipeFingerprint(parsed.recipe),
|
|
120
|
+
steps: recipeSteps(parsed.recipe),
|
|
121
|
+
commands: recipeCommands(parsed.recipe),
|
|
122
|
+
refusals: recipeRefusals(parsed.recipe),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Every command in the recipe, in the order a person should read them —
|
|
127
|
+
* `preview.run` and `preview.stop` included, exactly as the refusal check
|
|
128
|
+
* already covers them.
|
|
129
|
+
*/
|
|
130
|
+
export function recipeCommands(recipe) {
|
|
131
|
+
const entries = [];
|
|
132
|
+
for (const name of recipeSteps(recipe)) {
|
|
133
|
+
const run = recipe.steps?.[name]?.run;
|
|
134
|
+
if (run)
|
|
135
|
+
entries.push({ step: name, run });
|
|
136
|
+
}
|
|
137
|
+
if (recipe.preview?.run)
|
|
138
|
+
entries.push({ step: 'preview.run', run: recipe.preview.run });
|
|
139
|
+
if (recipe.preview?.stop)
|
|
140
|
+
entries.push({ step: 'preview.stop', run: recipe.preview.stop });
|
|
141
|
+
return entries.map(({ step, run }) => {
|
|
142
|
+
const masked = maskString(run);
|
|
143
|
+
return { step, run: masked, redacted: masked !== run };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Every command in the recipe that layer 1 would refuse, named by its step.
|
|
148
|
+
*
|
|
149
|
+
* Answered at read time, not at run time. A recipe whose `deploy` step contains
|
|
150
|
+
* `sudo systemctl restart` must fail the person reading the approval screen, not
|
|
151
|
+
* a build twenty minutes into a release.
|
|
152
|
+
*/
|
|
153
|
+
export function recipeRefusals(recipe) {
|
|
154
|
+
const refusals = [];
|
|
155
|
+
// A command a person cannot read is a command they cannot approve. Masking is
|
|
156
|
+
// a rewrite, and the executed copy is the unmasked one — so a redaction here
|
|
157
|
+
// is not a cosmetic detail, it is the gap between what was shown and what
|
|
158
|
+
// will run. Blocking approval closes it, and moving the secret into `env`
|
|
159
|
+
// (which is never a command) is the way out.
|
|
160
|
+
for (const command of recipeCommands(recipe)) {
|
|
161
|
+
if (!command.redacted)
|
|
162
|
+
continue;
|
|
163
|
+
refusals.push({
|
|
164
|
+
step: command.step,
|
|
165
|
+
reason: 'the command contains something that looks like a secret, so it cannot be shown in full — move it into `env` and approve a command you can read',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const name of recipeSteps(recipe)) {
|
|
169
|
+
const run = recipe.steps?.[name]?.run;
|
|
170
|
+
if (!run)
|
|
171
|
+
continue;
|
|
172
|
+
const decision = evaluateRecipeCommand(run);
|
|
173
|
+
if (!decision.allowed)
|
|
174
|
+
refusals.push({ step: name, reason: decision.reason ?? 'refused' });
|
|
175
|
+
}
|
|
176
|
+
if (recipe.preview?.run) {
|
|
177
|
+
const decision = evaluateRecipeCommand(recipe.preview.run);
|
|
178
|
+
if (!decision.allowed) {
|
|
179
|
+
refusals.push({ step: 'preview.run', reason: decision.reason ?? 'refused' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (recipe.preview?.stop) {
|
|
183
|
+
const decision = evaluateRecipeCommand(recipe.preview.stop, {
|
|
184
|
+
isPreviewStop: true,
|
|
185
|
+
...(recipe.preview.project ? { dockerProject: recipe.preview.project } : {}),
|
|
186
|
+
});
|
|
187
|
+
if (!decision.allowed) {
|
|
188
|
+
refusals.push({ step: 'preview.stop', reason: decision.reason ?? 'refused' });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return refusals;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Drop `//` and `/* *\/` comments without touching what is inside strings.
|
|
195
|
+
*
|
|
196
|
+
* A hand-rolled scanner rather than a regex: a regex that ignores string
|
|
197
|
+
* context eats the `//` in a URL, which is exactly what `health.url` is.
|
|
198
|
+
*/
|
|
199
|
+
export function stripJsonComments(text) {
|
|
200
|
+
let out = '';
|
|
201
|
+
let inString = false;
|
|
202
|
+
let escaped = false;
|
|
203
|
+
for (let i = 0; i < text.length; i++) {
|
|
204
|
+
const ch = text[i];
|
|
205
|
+
if (inString) {
|
|
206
|
+
out += ch;
|
|
207
|
+
if (escaped)
|
|
208
|
+
escaped = false;
|
|
209
|
+
else if (ch === '\\')
|
|
210
|
+
escaped = true;
|
|
211
|
+
else if (ch === '"')
|
|
212
|
+
inString = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (ch === '"') {
|
|
216
|
+
inString = true;
|
|
217
|
+
out += ch;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
221
|
+
while (i < text.length && text[i] !== '\n')
|
|
222
|
+
i++;
|
|
223
|
+
out += '\n';
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
227
|
+
i += 2;
|
|
228
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
|
|
229
|
+
i++;
|
|
230
|
+
i++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
out += ch;
|
|
234
|
+
}
|
|
235
|
+
// A trailing comma is the other thing a hand-written file grows.
|
|
236
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=recipe.js.map
|
package/dist/self-update.d.ts
CHANGED
|
@@ -45,6 +45,13 @@ export interface SelfUpdateOptions {
|
|
|
45
45
|
packageDir?: string | null;
|
|
46
46
|
/** Test seam: is this process supervised (defaults to autodetect). */
|
|
47
47
|
supervised?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Test seam for the resource-limits drop-in. Injected rather than called
|
|
50
|
+
* directly because the real one writes into `$HOME` — a test that exercised
|
|
51
|
+
* the update path would otherwise reconfigure the developer's own service.
|
|
52
|
+
* Returns true when it wrote something (and therefore needs a daemon-reload).
|
|
53
|
+
*/
|
|
54
|
+
writeLimits?: () => boolean;
|
|
48
55
|
}
|
|
49
56
|
/**
|
|
50
57
|
* Directory of the installed runner package, or null when it is running from a
|
|
@@ -55,6 +62,20 @@ export interface SelfUpdateOptions {
|
|
|
55
62
|
* has one above `packages/runner`.
|
|
56
63
|
*/
|
|
57
64
|
export declare function resolveInstalledPackageDir(entry?: string): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Can this user actually replace the installed package?
|
|
67
|
+
*
|
|
68
|
+
* The two legitimate installs part ways here. `npm install -g` run by root puts
|
|
69
|
+
* the package in `/usr/lib/node_modules` owned by root; the daemon then runs as
|
|
70
|
+
* a dedicated user, who cannot write there. The button was offered anyway and
|
|
71
|
+
* failed halfway through npm with «the permissions to access this file as the
|
|
72
|
+
* current user» — a dashboard button that cannot work, and an error in npm's
|
|
73
|
+
* words rather than ours.
|
|
74
|
+
*
|
|
75
|
+
* Both directories matter: npm rewrites the package AND the `bin` symlink, and
|
|
76
|
+
* either one being root-owned is enough to fail.
|
|
77
|
+
*/
|
|
78
|
+
export declare function installIsWritable(packageDir?: string | null): boolean;
|
|
58
79
|
/**
|
|
59
80
|
* Is something going to restart us?
|
|
60
81
|
*
|
package/dist/self-update.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
5
6
|
import { log } from './log.js';
|
|
6
7
|
import { stateDir } from './paths.js';
|
|
7
8
|
import { RUNNER_VERSION } from './version.js';
|
|
8
|
-
import { buildUnit, unitIsBroken, unitPath } from './service-unit.js';
|
|
9
|
+
import { buildUnit, limitsOverridePath, unitIsBroken, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const NPM_TIMEOUT_MS = 180_000;
|
|
11
12
|
const VERIFY_TIMEOUT_MS = 30_000;
|
|
@@ -54,6 +55,35 @@ export function resolveInstalledPackageDir(entry = process.argv[1] ?? '') {
|
|
|
54
55
|
}
|
|
55
56
|
return null;
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Can this user actually replace the installed package?
|
|
60
|
+
*
|
|
61
|
+
* The two legitimate installs part ways here. `npm install -g` run by root puts
|
|
62
|
+
* the package in `/usr/lib/node_modules` owned by root; the daemon then runs as
|
|
63
|
+
* a dedicated user, who cannot write there. The button was offered anyway and
|
|
64
|
+
* failed halfway through npm with «the permissions to access this file as the
|
|
65
|
+
* current user» — a dashboard button that cannot work, and an error in npm's
|
|
66
|
+
* words rather than ours.
|
|
67
|
+
*
|
|
68
|
+
* Both directories matter: npm rewrites the package AND the `bin` symlink, and
|
|
69
|
+
* either one being root-owned is enough to fail.
|
|
70
|
+
*/
|
|
71
|
+
export function installIsWritable(packageDir = resolveInstalledPackageDir()) {
|
|
72
|
+
if (!packageDir)
|
|
73
|
+
return false;
|
|
74
|
+
const writable = (target) => {
|
|
75
|
+
try {
|
|
76
|
+
fs.accessSync(target, fs.constants.W_OK);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
// `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>/lib/node_modules`
|
|
84
|
+
const nodeModules = path.dirname(packageDir.includes(`${path.sep}@`) ? path.dirname(packageDir) : packageDir);
|
|
85
|
+
return writable(packageDir) && writable(nodeModules);
|
|
86
|
+
}
|
|
57
87
|
/**
|
|
58
88
|
* Is something going to restart us?
|
|
59
89
|
*
|
|
@@ -218,6 +248,21 @@ export async function selfUpdate(options) {
|
|
|
218
248
|
if (!packageDir) {
|
|
219
249
|
return fail('This runner runs from a source checkout, not from an installed package — update it with git instead.');
|
|
220
250
|
}
|
|
251
|
+
// Installed by one user, run by another — the usual shape being `npm install
|
|
252
|
+
// -g` as root with the daemon under a dedicated user. npm gets far enough to
|
|
253
|
+
// start rewriting the package and then stops with EACCES, so the check has to
|
|
254
|
+
// happen BEFORE anything is touched. Refused with the two commands that work,
|
|
255
|
+
// because «run it as root» alone leaves out the restart, which needs the
|
|
256
|
+
// runner's own user.
|
|
257
|
+
if (!installIsWritable(packageDir)) {
|
|
258
|
+
const user = os.userInfo().username;
|
|
259
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : -1;
|
|
260
|
+
return fail(`The runner package in ${packageDir} belongs to another user, and this daemon runs as ${user}, ` +
|
|
261
|
+
'so it cannot replace itself. Update it on the server in two steps — install as root:\n' +
|
|
262
|
+
` npm install -g --ignore-scripts --loglevel=error ${options.tarballUrl}\n` +
|
|
263
|
+
'then restart the service as the runner’s own user:\n' +
|
|
264
|
+
` sudo -iu ${user} env XDG_RUNTIME_DIR=/run/user/${uid >= 0 ? uid : '$(id -u ' + user + ')'} systemctl --user restart devbridge-runner`);
|
|
265
|
+
}
|
|
221
266
|
// Pack the current version FIRST: without a rollback artefact there is no
|
|
222
267
|
// honest way back if the new build turns out to be broken.
|
|
223
268
|
const rollbackDir = path.join(stateDir(), 'rollback');
|
|
@@ -303,6 +348,33 @@ export async function selfUpdate(options) {
|
|
|
303
348
|
`could not be repaired (${describe(error)}). Run \`devbridge-runner install-service\` on the server.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
304
349
|
}
|
|
305
350
|
}
|
|
351
|
+
// The resource policy is versioned separately from the code, because the unit
|
|
352
|
+
// is written once at install time and the numbers baked into it before 0.21.0
|
|
353
|
+
// are what killed live sessions (QA-112 BLOCKER-1). An update is the only
|
|
354
|
+
// moment we are guaranteed to be running on the server with the right to fix
|
|
355
|
+
// that, so it happens here — and the restart below is what makes it effective.
|
|
356
|
+
//
|
|
357
|
+
// Deliberately NOT fatal: a runner that refuses to update because it could not
|
|
358
|
+
// improve its own limits is strictly worse than one that updates without the
|
|
359
|
+
// improvement. It is logged loudly and `doctor` reports it.
|
|
360
|
+
try {
|
|
361
|
+
if ((options.writeLimits ?? writeLimitsOverride)()) {
|
|
362
|
+
await exec('systemctl', ['--user', 'daemon-reload'], {
|
|
363
|
+
timeout: VERIFY_TIMEOUT_MS,
|
|
364
|
+
env: npmEnv(),
|
|
365
|
+
});
|
|
366
|
+
log.warn('self-update: resource limits drop-in written', {
|
|
367
|
+
path: limitsOverridePath(),
|
|
368
|
+
version: LIMITS_VERSION,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
log.error('self-update: could not write the resource limits drop-in', {
|
|
374
|
+
error: describe(error),
|
|
375
|
+
hint: 'run `devbridge-runner doctor --fix` on the server',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
306
378
|
log.info('self-update: installed', { fromVersion, toVersion });
|
|
307
379
|
return {
|
|
308
380
|
ok: true,
|
package/dist/service-unit.d.ts
CHANGED
|
@@ -15,12 +15,71 @@
|
|
|
15
15
|
*/
|
|
16
16
|
export declare const SERVICE_NAME = "devbridge-runner";
|
|
17
17
|
/** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
|
|
18
|
+
/**
|
|
19
|
+
* A path that will not exist after a reboot.
|
|
20
|
+
*
|
|
21
|
+
* `fnm` (and `nvm`/`volta` in the same spirit) puts the active version's `bin`
|
|
22
|
+
* into a per-shell directory under `/run/user/<uid>/fnm_multishells/…` — tmpfs,
|
|
23
|
+
* created for one shell session. `command -v devbridge-runner` resolves there,
|
|
24
|
+
* so baking it into a unit produces a service that works until the shell that
|
|
25
|
+
* installed it goes away, and then fails with status=127 forever. Observed on a
|
|
26
|
+
* real install, 2026-07-31, on a ROOT install — this is not a dedicated-user
|
|
27
|
+
* problem, it is a per-user node manager problem.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isEphemeralPath(target: string): boolean;
|
|
18
30
|
export declare function unitExecTarget(argv1?: string): {
|
|
19
31
|
execStart: string;
|
|
20
32
|
viaCommand: boolean;
|
|
21
33
|
};
|
|
22
|
-
export declare function unitPath(): string;
|
|
23
|
-
export declare function buildUnit(execStart?: string): string;
|
|
34
|
+
export declare function unitPath(home?: string): string;
|
|
35
|
+
export declare function buildUnit(execStart?: string, nodeBinary?: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Resource policy for the service, and why it does not live in the unit above.
|
|
38
|
+
*
|
|
39
|
+
* The unit is written ONCE, at install time. Until 0.21.0 it carried
|
|
40
|
+
* `CPUQuota=80%` and `MemoryMax=2G` — and those two lines cost a production
|
|
41
|
+
* server every session it was running (QA-112 BLOCKER-1, 2026-07-30):
|
|
42
|
+
*
|
|
43
|
+
* - every agent the runner starts, and every `pnpm build`/`tsc`/`vitest` that
|
|
44
|
+
* agent runs, is a CHILD of this service and therefore inside this cgroup.
|
|
45
|
+
* Three measured `claude` processes are 512/577/646 MB and one workspace
|
|
46
|
+
* `pnpm -r typecheck` peaks at 1571 MB, against a ceiling of 2048 MB;
|
|
47
|
+
* - `OOMPolicy` defaults to `stop`, so the kernel killing ONE of those children
|
|
48
|
+
* tore down the whole service — and with it every other session on the
|
|
49
|
+
* machine. `Restart=always` then brought the daemon back with an empty
|
|
50
|
+
* session map, which is what the user sees as
|
|
51
|
+
* «Runner reconnected. The session was resumed»;
|
|
52
|
+
* - `CPUQuota=80%` is 0.8 of ONE core for all of the above, which is separately
|
|
53
|
+
* what starves the event loop until the gateway's heartbeat gives up on it.
|
|
54
|
+
*
|
|
55
|
+
* So the policy is versioned and shipped as a drop-in instead. A drop-in can
|
|
56
|
+
* RESET a directive the main unit set (`MemoryMax=` with no value clears it),
|
|
57
|
+
* which is the only way to fix the servers that already have the bad numbers
|
|
58
|
+
* baked in — and it never overwrites a unit the operator edited by hand.
|
|
59
|
+
*/
|
|
60
|
+
export declare const LIMITS_VERSION = 2;
|
|
61
|
+
export declare function limitsOverridePath(home?: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
|
|
64
|
+
* box unreachable, but never so little that ordinary work is throttled.
|
|
65
|
+
*
|
|
66
|
+
* One core is reserved for the system (sshd, journald, the runner's own event
|
|
67
|
+
* loop). Below four cores there is nothing to reserve without crippling the
|
|
68
|
+
* agents, so no quota is set at all — an unusable dev server is a worse failure
|
|
69
|
+
* than a busy one.
|
|
70
|
+
*/
|
|
71
|
+
export declare function cpuQuotaPercent(cpuCount?: number): number | null;
|
|
72
|
+
export declare function buildLimitsOverride(cpuCount?: number): string;
|
|
73
|
+
/**
|
|
74
|
+
* Is the shipped resource policy missing or from an older runner?
|
|
75
|
+
*
|
|
76
|
+
* Deliberately version-based rather than content-based: an operator may add
|
|
77
|
+
* their own directives to our file, and re-writing on every start would fight
|
|
78
|
+
* them. Only the version number decides.
|
|
79
|
+
*/
|
|
80
|
+
export declare function limitsOverrideIsOutdated(readFile?: (p: string) => string, home?: string): boolean;
|
|
81
|
+
/** Write the drop-in. Returns false when nothing needed doing. */
|
|
82
|
+
export declare function writeLimitsOverride(force?: boolean, home?: string): boolean;
|
|
24
83
|
/**
|
|
25
84
|
* Does the installed unit point at something that no longer exists?
|
|
26
85
|
*
|
package/dist/service-unit.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { systemdUserHome } from './paths.js';
|
|
4
5
|
/**
|
|
5
6
|
* The systemd user unit, and the one decision inside it that matters: what to
|
|
6
7
|
* exec.
|
|
@@ -19,10 +20,31 @@ import path from 'node:path';
|
|
|
19
20
|
export const SERVICE_NAME = 'devbridge-runner';
|
|
20
21
|
const COMMAND_NAME = 'devbridge-runner';
|
|
21
22
|
/** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
|
|
23
|
+
/**
|
|
24
|
+
* A path that will not exist after a reboot.
|
|
25
|
+
*
|
|
26
|
+
* `fnm` (and `nvm`/`volta` in the same spirit) puts the active version's `bin`
|
|
27
|
+
* into a per-shell directory under `/run/user/<uid>/fnm_multishells/…` — tmpfs,
|
|
28
|
+
* created for one shell session. `command -v devbridge-runner` resolves there,
|
|
29
|
+
* so baking it into a unit produces a service that works until the shell that
|
|
30
|
+
* installed it goes away, and then fails with status=127 forever. Observed on a
|
|
31
|
+
* real install, 2026-07-31, on a ROOT install — this is not a dedicated-user
|
|
32
|
+
* problem, it is a per-user node manager problem.
|
|
33
|
+
*/
|
|
34
|
+
export function isEphemeralPath(target) {
|
|
35
|
+
// Deliberately narrow: `/run` and `/dev/shm` are tmpfs by definition, and
|
|
36
|
+
// `fnm_multishells` is named because fnm also offers non-tmpfs layouts. `/tmp`
|
|
37
|
+
// is NOT here — a package installed there is odd but survives, and tests build
|
|
38
|
+
// their fake installs in temp directories.
|
|
39
|
+
return (/^\/(run|proc)\//.test(target) ||
|
|
40
|
+
target.startsWith('/dev/shm/') ||
|
|
41
|
+
target.includes('fnm_multishells'));
|
|
42
|
+
}
|
|
22
43
|
export function unitExecTarget(argv1 = process.argv[1] ?? '') {
|
|
23
|
-
// Invoked through the command itself: that is already the stable path
|
|
44
|
+
// Invoked through the command itself: that is already the stable path —
|
|
45
|
+
// unless it lives in a directory that disappears with the shell.
|
|
24
46
|
try {
|
|
25
|
-
if (fs.lstatSync(argv1).isSymbolicLink()) {
|
|
47
|
+
if (fs.lstatSync(argv1).isSymbolicLink() && !isEphemeralPath(path.resolve(argv1))) {
|
|
26
48
|
return { execStart: path.resolve(argv1), viaCommand: true };
|
|
27
49
|
}
|
|
28
50
|
}
|
|
@@ -42,8 +64,9 @@ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
|
|
|
42
64
|
let dir = path.dirname(script);
|
|
43
65
|
for (let i = 0; i < 6; i++) {
|
|
44
66
|
const candidate = path.join(dir, 'bin', COMMAND_NAME);
|
|
45
|
-
if (fs.existsSync(candidate))
|
|
67
|
+
if (fs.existsSync(candidate) && !isEphemeralPath(candidate)) {
|
|
46
68
|
return { execStart: candidate, viaCommand: true };
|
|
69
|
+
}
|
|
47
70
|
const parent = path.dirname(dir);
|
|
48
71
|
if (parent === dir)
|
|
49
72
|
break;
|
|
@@ -52,16 +75,22 @@ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
|
|
|
52
75
|
}
|
|
53
76
|
return { execStart: script, viaCommand: false };
|
|
54
77
|
}
|
|
55
|
-
export function unitPath() {
|
|
56
|
-
return path.join(
|
|
78
|
+
export function unitPath(home = systemdUserHome()) {
|
|
79
|
+
return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
|
|
57
80
|
}
|
|
58
|
-
export function buildUnit(execStart) {
|
|
81
|
+
export function buildUnit(execStart, nodeBinary = process.execPath) {
|
|
59
82
|
const target = execStart ?? unitExecTarget().execStart;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The interpreter is ALWAYS spelled out, even for the command symlink.
|
|
85
|
+
*
|
|
86
|
+
* The file carries `#!/usr/bin/env node`, and relying on that shebang means
|
|
87
|
+
* relying on `node` being on the PATH systemd gives the service — which it is
|
|
88
|
+
* not when node came from fnm/nvm/volta. That produced `status=127` and an
|
|
89
|
+
* endless restart loop on a real machine. Node runs a file with a shebang
|
|
90
|
+
* perfectly well (it is a comment to it), so naming the interpreter costs
|
|
91
|
+
* nothing and removes the dependency entirely.
|
|
92
|
+
*/
|
|
93
|
+
const command = `${nodeBinary} ${target} daemon`;
|
|
65
94
|
return ([
|
|
66
95
|
'[Unit]',
|
|
67
96
|
'Description=DevBridge Dev Runner',
|
|
@@ -71,13 +100,117 @@ export function buildUnit(execStart) {
|
|
|
71
100
|
`ExecStart=${command}`,
|
|
72
101
|
'Restart=always',
|
|
73
102
|
'RestartSec=5',
|
|
74
|
-
'CPUQuota=80%',
|
|
75
|
-
'MemoryMax=2G',
|
|
76
103
|
'',
|
|
77
104
|
'[Install]',
|
|
78
105
|
'WantedBy=default.target',
|
|
79
106
|
].join('\n') + '\n');
|
|
80
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Resource policy for the service, and why it does not live in the unit above.
|
|
110
|
+
*
|
|
111
|
+
* The unit is written ONCE, at install time. Until 0.21.0 it carried
|
|
112
|
+
* `CPUQuota=80%` and `MemoryMax=2G` — and those two lines cost a production
|
|
113
|
+
* server every session it was running (QA-112 BLOCKER-1, 2026-07-30):
|
|
114
|
+
*
|
|
115
|
+
* - every agent the runner starts, and every `pnpm build`/`tsc`/`vitest` that
|
|
116
|
+
* agent runs, is a CHILD of this service and therefore inside this cgroup.
|
|
117
|
+
* Three measured `claude` processes are 512/577/646 MB and one workspace
|
|
118
|
+
* `pnpm -r typecheck` peaks at 1571 MB, against a ceiling of 2048 MB;
|
|
119
|
+
* - `OOMPolicy` defaults to `stop`, so the kernel killing ONE of those children
|
|
120
|
+
* tore down the whole service — and with it every other session on the
|
|
121
|
+
* machine. `Restart=always` then brought the daemon back with an empty
|
|
122
|
+
* session map, which is what the user sees as
|
|
123
|
+
* «Runner reconnected. The session was resumed»;
|
|
124
|
+
* - `CPUQuota=80%` is 0.8 of ONE core for all of the above, which is separately
|
|
125
|
+
* what starves the event loop until the gateway's heartbeat gives up on it.
|
|
126
|
+
*
|
|
127
|
+
* So the policy is versioned and shipped as a drop-in instead. A drop-in can
|
|
128
|
+
* RESET a directive the main unit set (`MemoryMax=` with no value clears it),
|
|
129
|
+
* which is the only way to fix the servers that already have the bad numbers
|
|
130
|
+
* baked in — and it never overwrites a unit the operator edited by hand.
|
|
131
|
+
*/
|
|
132
|
+
export const LIMITS_VERSION = 2;
|
|
133
|
+
const LIMITS_MARKER = '# devbridge-limits-version:';
|
|
134
|
+
/** `zz-` so it sorts last: an operator's own drop-in should still win. */
|
|
135
|
+
const LIMITS_FILE = 'zz-devbridge-limits.conf';
|
|
136
|
+
export function limitsOverridePath(home = systemdUserHome()) {
|
|
137
|
+
return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service.d`, LIMITS_FILE);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
|
|
141
|
+
* box unreachable, but never so little that ordinary work is throttled.
|
|
142
|
+
*
|
|
143
|
+
* One core is reserved for the system (sshd, journald, the runner's own event
|
|
144
|
+
* loop). Below four cores there is nothing to reserve without crippling the
|
|
145
|
+
* agents, so no quota is set at all — an unusable dev server is a worse failure
|
|
146
|
+
* than a busy one.
|
|
147
|
+
*/
|
|
148
|
+
export function cpuQuotaPercent(cpuCount = os.cpus().length) {
|
|
149
|
+
return cpuCount >= 4 ? (cpuCount - 1) * 100 : null;
|
|
150
|
+
}
|
|
151
|
+
export function buildLimitsOverride(cpuCount = os.cpus().length) {
|
|
152
|
+
const quota = cpuQuotaPercent(cpuCount);
|
|
153
|
+
return ([
|
|
154
|
+
`${LIMITS_MARKER} ${LIMITS_VERSION}`,
|
|
155
|
+
'# Managed by devbridge-runner. Put your own overrides in a file that sorts',
|
|
156
|
+
'# after this one, or edit the unit itself — neither is touched by updates.',
|
|
157
|
+
'',
|
|
158
|
+
'[Unit]',
|
|
159
|
+
// Five fast failures used to leave the service in `failed` and the server
|
|
160
|
+
// offline until someone logged in. A dev runner must always come back;
|
|
161
|
+
// crash loops are surfaced through `lastExit` in hello, not by giving up.
|
|
162
|
+
'StartLimitIntervalSec=0',
|
|
163
|
+
'StartLimitBurst=0',
|
|
164
|
+
'',
|
|
165
|
+
'[Service]',
|
|
166
|
+
// The whole point of the change: one child's OOM must not take the fleet.
|
|
167
|
+
'OOMPolicy=continue',
|
|
168
|
+
// Clears `MemoryMax=2G` from units written before 0.21.0.
|
|
169
|
+
'MemoryMax=',
|
|
170
|
+
// Soft pressure instead of a hard ceiling: the kernel reclaims and
|
|
171
|
+
// throttles rather than killing, and the machine keeps a fifth of its
|
|
172
|
+
// memory for everything that is not this service.
|
|
173
|
+
'MemoryHigh=80%',
|
|
174
|
+
// Either a computed quota, or an explicit reset — both of which clear the
|
|
175
|
+
// `CPUQuota=80%` baked into units written before 0.21.0.
|
|
176
|
+
quota === null ? 'CPUQuota=' : `CPUQuota=${quota}%`,
|
|
177
|
+
// Needed for `doctor` and for the memory/CPU figures the runner reports.
|
|
178
|
+
'MemoryAccounting=yes',
|
|
179
|
+
'CPUAccounting=yes',
|
|
180
|
+
// An agent running a monorepo build forks a lot; the default is per-user.
|
|
181
|
+
'TasksMax=8192',
|
|
182
|
+
].join('\n') + '\n');
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Is the shipped resource policy missing or from an older runner?
|
|
186
|
+
*
|
|
187
|
+
* Deliberately version-based rather than content-based: an operator may add
|
|
188
|
+
* their own directives to our file, and re-writing on every start would fight
|
|
189
|
+
* them. Only the version number decides.
|
|
190
|
+
*/
|
|
191
|
+
export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'utf8'), home = systemdUserHome()) {
|
|
192
|
+
let contents;
|
|
193
|
+
try {
|
|
194
|
+
contents = readFile(limitsOverridePath(home));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return true; // never written — every server that predates 0.21.0
|
|
198
|
+
}
|
|
199
|
+
const line = contents.split('\n').find((l) => l.startsWith(LIMITS_MARKER));
|
|
200
|
+
if (!line)
|
|
201
|
+
return true;
|
|
202
|
+
const version = Number.parseInt(line.slice(LIMITS_MARKER.length).trim(), 10);
|
|
203
|
+
return !Number.isFinite(version) || version < LIMITS_VERSION;
|
|
204
|
+
}
|
|
205
|
+
/** Write the drop-in. Returns false when nothing needed doing. */
|
|
206
|
+
export function writeLimitsOverride(force = false, home = systemdUserHome()) {
|
|
207
|
+
if (!force && !limitsOverrideIsOutdated(undefined, home))
|
|
208
|
+
return false;
|
|
209
|
+
const target = limitsOverridePath(home);
|
|
210
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
211
|
+
fs.writeFileSync(target, buildLimitsOverride(), { mode: 0o644 });
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
81
214
|
/**
|
|
82
215
|
* Does the installed unit point at something that no longer exists?
|
|
83
216
|
*
|
|
@@ -101,6 +234,9 @@ export function unitIsBroken(readFile = (p) => fs.readFileSync(p, 'utf8')) {
|
|
|
101
234
|
const target = parts[0]?.endsWith('node') ? parts[1] : parts[0];
|
|
102
235
|
if (!target)
|
|
103
236
|
return false;
|
|
104
|
-
|
|
237
|
+
// Gone already, or living somewhere that will be gone after a reboot — the
|
|
238
|
+
// second one still runs today, which is exactly why it has to be repaired
|
|
239
|
+
// before the reboot rather than after it.
|
|
240
|
+
return !fs.existsSync(target) || isEphemeralPath(target) || isEphemeralPath(parts[0] ?? '');
|
|
105
241
|
}
|
|
106
242
|
//# sourceMappingURL=service-unit.js.map
|