@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.
Files changed (49) 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/auth-relay.d.ts +33 -3
  11. package/dist/auth-relay.js +199 -16
  12. package/dist/auto-resume.d.ts +18 -0
  13. package/dist/auto-resume.js +104 -0
  14. package/dist/commit-message.d.ts +51 -0
  15. package/dist/commit-message.js +224 -0
  16. package/dist/config.d.ts +29 -6
  17. package/dist/config.js +15 -0
  18. package/dist/crash-note.d.ts +54 -0
  19. package/dist/crash-note.js +105 -0
  20. package/dist/environment.d.ts +171 -0
  21. package/dist/environment.js +409 -0
  22. package/dist/git.d.ts +81 -0
  23. package/dist/git.js +301 -15
  24. package/dist/gitops.d.ts +489 -12
  25. package/dist/gitops.js +1717 -96
  26. package/dist/index.js +715 -8
  27. package/dist/paths.d.ts +35 -0
  28. package/dist/paths.js +45 -0
  29. package/dist/policy.d.ts +63 -0
  30. package/dist/policy.js +412 -10
  31. package/dist/protocol.d.ts +382 -60
  32. package/dist/protocol.js +104 -1
  33. package/dist/recipe-schema.d.ts +310 -0
  34. package/dist/recipe-schema.js +103 -0
  35. package/dist/recipe.d.ts +94 -0
  36. package/dist/recipe.js +238 -0
  37. package/dist/self-update.d.ts +21 -0
  38. package/dist/self-update.js +73 -1
  39. package/dist/service-unit.d.ts +61 -2
  40. package/dist/service-unit.js +150 -14
  41. package/dist/supervisor.d.ts +108 -1
  42. package/dist/supervisor.js +1045 -57
  43. package/dist/verify-queue.d.ts +17 -0
  44. package/dist/verify-queue.js +100 -0
  45. package/dist/verify.d.ts +203 -0
  46. package/dist/verify.js +788 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. 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
@@ -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
  *
@@ -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,
@@ -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
  *
@@ -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(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
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
- // The command carries a `#!/usr/bin/env node` shebang, so it is exec'd directly;
61
- // a bare script path needs the interpreter spelled out.
62
- const command = target.endsWith('.js')
63
- ? `${process.execPath} ${target} daemon`
64
- : `${target} daemon`;
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
- return !fs.existsSync(target);
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