@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/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } from 'node:child_process';
3
+ import crypto from 'node:crypto';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
@@ -9,13 +10,27 @@ import { CodexAdapter } from './adapters/codex.js';
9
10
  import { ensureCodexHome } from './adapters/codex-home.js';
10
11
  import { loadConfig, requireConfig, saveConfig } from './config.js';
11
12
  import { log } from './log.js';
12
- import { isSupervisedProcess, resolveInstalledPackageDir } from './self-update.js';
13
+ import { installIsWritable, isSupervisedProcess, resolveInstalledPackageDir, } from './self-update.js';
13
14
  import { Supervisor } from './supervisor.js';
14
15
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
15
16
  import { RunnerWsClient } from './ws-client.js';
16
17
  import { RUNNER_VERSION } from './version.js';
17
- import { buildUnit, unitExecTarget, unitPath } from './service-unit.js';
18
+ import { buildUnit, cpuQuotaPercent, limitsOverrideIsOutdated, limitsOverridePath, unitExecTarget, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
19
+ import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
20
+ import { agentAuthStatuses } from './auth-relay.js';
21
+ import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, systemdUserEnv, } from './environment.js';
22
+ import { mcpConfigDir } from './paths.js';
18
23
  const execFileAsync = promisify(execFile);
24
+ /**
25
+ * Identity of this process, and the note the previous one left.
26
+ *
27
+ * Read once at module load, before anything can connect: `takeLastExit()`
28
+ * CONSUMES the note, so it must happen exactly once per process — reading it
29
+ * per reconnect would report the same death forever (QA-112).
30
+ */
31
+ const INSTANCE_ID = crypto.randomUUID();
32
+ const STARTED_AT = new Date().toISOString();
33
+ const LAST_EXIT = takeLastExit();
19
34
  function print(line) {
20
35
  process.stdout.write(line + '\n');
21
36
  }
@@ -47,7 +62,28 @@ function installedAgents() {
47
62
  * instead of a button that would fail on tap.
48
63
  */
49
64
  function selfUpdatable() {
50
- return resolveInstalledPackageDir() !== null && isSupervisedProcess();
65
+ const packageDir = resolveInstalledPackageDir();
66
+ return packageDir !== null && isSupervisedProcess() && installIsWritable(packageDir);
67
+ }
68
+ /**
69
+ * Why this runner cannot replace itself — so the card can SAY it.
70
+ *
71
+ * Both halves look identical from the dashboard (no button), and "why does this
72
+ * machine have a button and that one doesn't" is exactly the question the page
73
+ * should not leave hanging. Reported as a string rather than inferred, because
74
+ * only the runner knows how it was started.
75
+ */
76
+ function selfUpdateBlockedReason() {
77
+ const packageDir = resolveInstalledPackageDir();
78
+ if (packageDir === null)
79
+ return 'source-checkout';
80
+ if (!isSupervisedProcess())
81
+ return 'unsupervised';
82
+ // Installed by one user, run by another: npm would fail halfway through with
83
+ // EACCES. Reported so the card shows the command instead of a button.
84
+ if (!installIsWritable(packageDir))
85
+ return 'not-writable';
86
+ return null;
51
87
  }
52
88
  function hasExecutable(name) {
53
89
  const dirs = (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean);
@@ -68,7 +104,12 @@ function hasExecutable(name) {
68
104
  * command it does not know would just hang until the gateway timeout.
69
105
  */
70
106
  function runnerCapabilities() {
71
- const localLimit = loadConfig()?.limits?.max_sessions;
107
+ const config = loadConfig();
108
+ const localLimit = config?.limits?.max_sessions;
109
+ // Session 14: the machine owner's veto. Default on — the safety of the
110
+ // feature is that a DevBridge manager approves every command first — but the
111
+ // person who owns the server gets the last word on whether it exists here.
112
+ const verifyEnabled = config?.verify?.enabled !== false;
72
113
  return {
73
114
  agents: installedAgents(),
74
115
  git: true,
@@ -83,7 +124,17 @@ function runnerCapabilities() {
83
124
  * installed package under a supervisor — a source checkout or a hand-started
84
125
  * daemon says so here, so the button never appears where it cannot work.
85
126
  */
86
- ...(selfUpdatable() ? { selfUpdate: true } : {}),
127
+ ...(selfUpdatable()
128
+ ? { selfUpdate: true }
129
+ : { selfUpdateBlocked: selfUpdateBlockedReason() ?? 'unsupervised' }),
130
+ /**
131
+ * Which OS user this daemon runs as (0.24.0).
132
+ *
133
+ * The card needs it to write a command that will actually work: an update
134
+ * installed by root has to be restarted as THIS user, and «run it as root»
135
+ * is only half the instruction without a name to restart under.
136
+ */
137
+ runnerUser: runnerIdentity().user,
87
138
  /**
88
139
  * A stricter ceiling set on the machine itself (layer 1). Reported so the
89
140
  * dashboard can explain why raising the number there changed nothing.
@@ -99,8 +150,144 @@ function runnerCapabilities() {
99
150
  * History tab rather than showing a tab that answers "unknown command".
100
151
  */
101
152
  gitHistory: true,
153
+ /**
154
+ * Session 12: parks `AskUserQuestion` and understands the `question_answer`
155
+ * frame. The API refuses to accept an answer without it — otherwise the
156
+ * frame would leave, the socket would report success, and the runner would
157
+ * drop it: the silent failure this whole session exists to remove.
158
+ */
159
+ questionCards: true,
160
+ /**
161
+ * Session 13: git is a workplace, not a one-way door.
162
+ *
163
+ * Understands `branchPlan` (create vs continue, with a pinned fork point),
164
+ * answers `git_branches` / `update_from_base` / `git_push`, and reports
165
+ * ahead/behind/drift in `git_status`. One flag rather than four: they ship
166
+ * together and the panel needs all of them to say anything true.
167
+ */
168
+ gitWorkspace: true,
169
+ /**
170
+ * Session 14: the graph became a repository viewer.
171
+ *
172
+ * `git_refs`, a `git_log` that takes an explicit ref set, typed
173
+ * decorations, the committer in both formats, and `workspace_state`. One
174
+ * flag rather than five: the graph needs all of them at once to draw
175
+ * anything the previous version could not.
176
+ */
177
+ gitRefs: true,
178
+ /**
179
+ * Session 14: reads `.devbridge/project.json`. Announced even when
180
+ * verification is switched off — «this machine will not run recipes» and
181
+ * «this project has no recipe» are different sentences and the card has to
182
+ * be able to say both.
183
+ */
184
+ projectRecipe: true,
185
+ /**
186
+ * Session 14: can execute an APPROVED recipe and report a verdict.
187
+ *
188
+ * Withheld when the machine's owner set `[verify] enabled = false`, and
189
+ * withheld means the dashboard draws no card at all — a control that is
190
+ * switched off must not look like a control that is broken.
191
+ */
192
+ ...(verifyEnabled ? { verifyRuns: true } : { verifyBlocked: 'disabled-by-config' }),
193
+ /** Session 14: a second, detached worktree driven only by `preview`. */
194
+ ...(verifyEnabled ? { previewWorktrees: true } : {}),
195
+ /** Session 14: one-shot agent-written commit messages. */
196
+ commitMessages: true,
197
+ /**
198
+ * Session 15: `git_status` reports whether the PROJECT FOLDER is clean, so
199
+ * the panel can say what is blocking an Apply instead of offering a button
200
+ * that the runner will refuse.
201
+ */
202
+ workspaceDirtyReporting: true,
203
+ /**
204
+ * Session 15: reads `agentAutoCommit` off the workspace and honours it on
205
+ * every tool call rather than at launch.
206
+ *
207
+ * Its own flag now. It used to ride on `workspaceStash`, which was fine
208
+ * while the two shipped together and wrong the moment one of them was
209
+ * deleted — the settings card would have started calling every up-to-date
210
+ * runner incapable of the setting it honours.
211
+ */
212
+ agentAutoCommit: true,
213
+ /**
214
+ * Session 16: git is git.
215
+ *
216
+ * `git_stage` / `git_unstage` / `git_discard` / `git_pull` /
217
+ * `git_merge_abort`, a `git_status` that reports the working tree the way
218
+ * `git status` does (both columns, upstream, ahead/behind, merge in
219
+ * progress), a `git_diff` that can show either side of the index, a
220
+ * `git_commit` that can commit just what was staged, and `workMode:
221
+ * DIRECT` — a session that works in the project folder itself.
222
+ *
223
+ * One flag for all of it because the Source Control panel needs all of it:
224
+ * a stage button next to a status that cannot say what is staged would be a
225
+ * control with nothing to control.
226
+ */
227
+ sourceControl: true,
228
+ /**
229
+ * Session 17 (#113): `agent_tasks` — the live set of subagents, background
230
+ * shells and dynamic workflows the agent is running beside the
231
+ * conversation, published as a level signal with REPLACE semantics.
232
+ *
233
+ * Nothing gates on it yet — the tray simply stays empty on a runner that
234
+ * sends no `agent_tasks`, which is the right behaviour either way. It is
235
+ * advertised now because a capability has to be on the wire BEFORE anything
236
+ * can gate on it: the runner ships to machines on its own schedule, and a
237
+ * flag added at the same time as its first reader is a flag that reads
238
+ * `false` on every server that has not been updated yet.
239
+ */
240
+ agentTasks: true,
241
+ /**
242
+ * Session 18 (QA-112): the facts DevBridge needs to stop guessing.
243
+ *
244
+ * `maxSessions` was a number out of thin air (default 3) while this machine
245
+ * silently decided whether three agents fit. Three measured `claude`
246
+ * processes are ~1.7 GB and one workspace typecheck peaks at 1.5 GB, so on a
247
+ * small box the answer is no — and the only symptom used to be a dead runner.
248
+ * Reported so the dashboard can recommend a ceiling and explain a refusal.
249
+ */
250
+ machine: {
251
+ cpuCount: os.cpus().length,
252
+ memTotalBytes: os.totalmem(),
253
+ memAvailableBytes: os.freemem(),
254
+ limitsVersion: LIMITS_VERSION,
255
+ limitsCurrent: !limitsOverrideIsOutdated(),
256
+ },
257
+ /**
258
+ * Identity of THIS process, so the API can tell a network blink from a
259
+ * restart. Without it `dev-runner: connected activeSessions: 3` reads the
260
+ * same either way, which is why the incident's six drops could not be
261
+ * classified (QA-112 MAJOR-4).
262
+ */
263
+ instanceId: INSTANCE_ID,
264
+ startedAt: STARTED_AT,
265
+ /**
266
+ * How the PREVIOUS process died, consumed once. Absent on a clean start.
267
+ * `kind: 'oom'` is inferred from the cgroup counter — see `crash-note.ts`.
268
+ */
269
+ ...(LAST_EXIT ? { lastExit: LAST_EXIT } : {}),
102
270
  /** Commands beyond the stage-A/B set. */
103
- commands: ['purge_session', 'git_log', 'git_show'],
271
+ commands: [
272
+ 'purge_session',
273
+ 'git_log',
274
+ 'git_show',
275
+ 'git_branches',
276
+ 'update_from_base',
277
+ 'git_push',
278
+ 'git_refs',
279
+ 'workspace_state',
280
+ 'git_stage',
281
+ 'git_unstage',
282
+ 'git_discard',
283
+ 'git_pull',
284
+ 'git_merge_abort',
285
+ 'recipe_state',
286
+ 'propose_commit_message',
287
+ ...(verifyEnabled
288
+ ? ['verify_start', 'verify_status', 'verify_cancel', 'preview_checkout', 'preview_stop']
289
+ : []),
290
+ ],
104
291
  };
105
292
  }
106
293
  // ─── pair ────────────────────────────────────────────────────────────
@@ -165,9 +352,83 @@ function bootstrapCodex(config) {
165
352
  }
166
353
  /** Long enough for the `command_result` frame to leave the socket. */
167
354
  const RESTART_DELAY_MS = 1_500;
355
+ /**
356
+ * Bring this machine's resource policy up to date, on every daemon start.
357
+ *
358
+ * `self-update` also does this, and that is NOT enough: the update is executed by
359
+ * the process being replaced, so the code that writes the drop-in only exists in
360
+ * the build being INSTALLED. Updating 0.20.1 → 0.21.0 therefore left the bad
361
+ * `MemoryMax=2G` in place and the runner reported `limitsCurrent: false` on its
362
+ * first hello — caught live on vmi3328440, 2026-07-30 03:35 UTC.
363
+ *
364
+ * A migration that only runs during an update can never fix the version that
365
+ * introduces it. Startup is the place that always belongs to the new build.
366
+ *
367
+ * Idempotent by version marker, so a normal restart writes nothing. Never fatal:
368
+ * a runner that refuses to start because it could not improve its own limits is
369
+ * strictly worse than one that starts without the improvement.
370
+ */
371
+ async function repairResourceLimits() {
372
+ try {
373
+ if (!writeLimitsOverride())
374
+ return;
375
+ log.warn('daemon: resource limits drop-in written — reloading systemd', {
376
+ path: limitsOverridePath(),
377
+ version: LIMITS_VERSION,
378
+ });
379
+ await execFileAsync('systemctl', ['--user', 'daemon-reload'], {
380
+ timeout: 15_000,
381
+ env: systemdUserEnv(),
382
+ });
383
+ // Deliberately no restart: `daemon-reload` alone is enough for these
384
+ // directives (verified live — OOMPolicy went stop→continue and MemoryMax
385
+ // 2G→infinity with the PID unchanged), and restarting here would park every
386
+ // live session to apply a setting that is already in force.
387
+ log.info('daemon: resource limits applied without a restart');
388
+ }
389
+ catch (error) {
390
+ log.error('daemon: could not apply the resource limits drop-in', {
391
+ error: String(error instanceof Error ? error.message : error),
392
+ hint: 'run `devbridge-runner doctor --fix`',
393
+ });
394
+ }
395
+ }
396
+ /**
397
+ * Ticket #119. `ClaudeAdapter.stop()` removes each session's MCP config file,
398
+ * but a kill -9, an OOM or a machine reboot leaves it behind — a live project
399
+ * key sitting on disk with nothing left that will ever clean it up. Nothing is
400
+ * running yet at this point in the boot, so every file here is by definition an
401
+ * orphan of a previous process.
402
+ */
403
+ function sweepOrphanedMcpConfigs() {
404
+ const dir = mcpConfigDir();
405
+ let entries;
406
+ try {
407
+ entries = fs.readdirSync(dir);
408
+ }
409
+ catch {
410
+ return; // never created — the normal case on a fresh install
411
+ }
412
+ let removed = 0;
413
+ for (const name of entries) {
414
+ if (!name.startsWith('devbridge-mcp.') || !name.endsWith('.json'))
415
+ continue;
416
+ try {
417
+ fs.unlinkSync(path.join(dir, name));
418
+ removed += 1;
419
+ }
420
+ catch {
421
+ // Leave it; a warning per file would be noise.
422
+ }
423
+ }
424
+ if (removed > 0)
425
+ log.warn('daemon: removed orphaned MCP config files', { count: removed });
426
+ }
168
427
  async function cmdDaemon() {
169
428
  const config = requireConfig();
170
429
  log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
430
+ sweepOrphanedMcpConfigs();
431
+ await repairResourceLimits();
171
432
  const agents = installedAgents();
172
433
  const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
173
434
  const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
@@ -178,6 +439,9 @@ async function cmdDaemon() {
178
439
  },
179
440
  ...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
180
441
  ...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
442
+ // The same veto the capability list honours — announced AND enforced, so a
443
+ // frame from an API that has not noticed still cannot start a run.
444
+ verifyEnabled: config.verify?.enabled !== false,
181
445
  apiUrl: config.api.url,
182
446
  // Used to fetch the files a user attaches to a message (session 10) — the
183
447
  // same token the WS connection authenticates with, never passed onwards.
@@ -231,6 +495,37 @@ async function cmdDaemon() {
231
495
  };
232
496
  process.on('SIGINT', () => shutdown('SIGINT'));
233
497
  process.on('SIGTERM', () => shutdown('SIGTERM'));
498
+ /**
499
+ * A death must leave a note. Until 0.21.0 the only handlers here were the two
500
+ * signals above, so an unhandled error exited the process silently and the
501
+ * only trace anywhere was systemd's `Scheduled restart job` — which is why the
502
+ * 2026-07-30 incident could not be classified as OOM-vs-crash after the fact
503
+ * (QA-112 MAJOR-4).
504
+ *
505
+ * `crashFile` is read by the next process and reported in `hello`, so the
506
+ * cause survives the restart and reaches the dashboard.
507
+ */
508
+ const noteFatal = (kind, error) => {
509
+ const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
510
+ log.error(`daemon: ${kind} — exiting for a restart`, { detail: detail.slice(0, 2000) });
511
+ try {
512
+ recordCrash({
513
+ kind,
514
+ detail: detail.slice(0, 2000),
515
+ at: new Date().toISOString(),
516
+ version: RUNNER_VERSION,
517
+ activeSessionIds: supervisor.activeSessionIds,
518
+ });
519
+ }
520
+ catch {
521
+ /* the log line above is already on disk via journald */
522
+ }
523
+ // Not a graceful supervisor.shutdown(): the process state is by definition
524
+ // untrustworthy here, and systemd `Restart=always` is the recovery path.
525
+ process.exit(1);
526
+ };
527
+ process.on('uncaughtException', (error) => noteFatal('uncaughtException', error));
528
+ process.on('unhandledRejection', (reason) => noteFatal('unhandledRejection', reason));
234
529
  ws.start();
235
530
  updateStatus();
236
531
  // Keep the process alive forever; ws timers drive everything.
@@ -289,14 +584,21 @@ async function cmdInstallService() {
289
584
  const exec = unitExecTarget();
290
585
  fs.writeFileSync(target, buildUnit(exec.execStart));
291
586
  print(`Wrote ${target}`);
587
+ // Resource policy is a versioned drop-in, not part of the unit — see
588
+ // `buildLimitsOverride`. Forced here: a fresh install must have it even if a
589
+ // file from an older runner is already sitting there.
590
+ writeLimitsOverride(true);
591
+ print(`Wrote ${limitsOverridePath()}`);
292
592
  if (!exec.viaCommand) {
293
593
  // Worth saying out loud: a unit pinned to a file inside the package directory
294
594
  // breaks if that directory ever moves (which a package rename does).
295
595
  print(`note: the service runs ${exec.execStart} directly — re-run install-service after reinstalling the package.`);
296
596
  }
297
597
  try {
298
- await execFileAsync('systemctl', ['--user', 'daemon-reload']);
299
- await execFileAsync('systemctl', ['--user', 'enable', '--now', 'devbridge-runner']);
598
+ await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
599
+ await execFileAsync('systemctl', ['--user', 'enable', '--now', 'devbridge-runner'], {
600
+ env: systemdUserEnv(),
601
+ });
300
602
  print('Service enabled and started (systemctl --user).');
301
603
  }
302
604
  catch (error) {
@@ -312,6 +614,399 @@ async function cmdInstallService() {
312
614
  }
313
615
  print('Verify with: devbridge-runner status');
314
616
  }
617
+ function printCheck(check) {
618
+ print(` ${check.ok ? '✔' : '✘'} ${check.name.padEnd(15)} ${check.detail}`);
619
+ if (check.fix)
620
+ print(` → ${check.fix}`);
621
+ if (check.fixMore)
622
+ print(` ${check.fixMore}`);
623
+ }
624
+ /** One property of the user service, or null when systemd cannot answer. */
625
+ async function systemctlProperty(name) {
626
+ try {
627
+ const { stdout } = await execFileAsync('systemctl', ['--user', 'show', 'devbridge-runner', '-p', name, '--value'], { timeout: 10_000, env: systemdUserEnv() });
628
+ const value = stdout.trim();
629
+ return value.length > 0 ? value : null;
630
+ }
631
+ catch {
632
+ return null;
633
+ }
634
+ }
635
+ async function runnerChecks() {
636
+ const me = runnerIdentity();
637
+ const checks = [];
638
+ const busReachable = await systemdUserBusReachable();
639
+ const linger = await lingerEnabled();
640
+ if (!busReachable) {
641
+ checks.push({
642
+ ok: false,
643
+ name: 'service',
644
+ detail: 'systemd user session NOT reachable',
645
+ // `systemctl --user` prints «Failed to connect to bus» and exits 0, so a
646
+ // health check reads that as success. Give the form that works.
647
+ fix: systemctlHint('status devbridge-runner'),
648
+ });
649
+ }
650
+ else {
651
+ // The bus answering says nothing about the service. Reporting READY over a
652
+ // stopped daemon is worse than having no acceptance at all — an acceptance
653
+ // that lies is what sends somebody away from a server that does not work.
654
+ const state = await systemctlProperty('ActiveState');
655
+ const enabled = await systemctlProperty('UnitFileState');
656
+ const running = state === 'active';
657
+ checks.push({
658
+ ok: running,
659
+ name: 'service',
660
+ detail: running
661
+ ? `running${enabled === 'enabled' ? ', starts on boot' : ' — but NOT enabled: it will not come back after a reboot'}` +
662
+ (linger === true
663
+ ? ', survives logout'
664
+ : linger === false
665
+ ? ', but linger is OFF: it stops when this user logs out'
666
+ : '')
667
+ : `NOT running (${state ?? 'unknown'})`,
668
+ ...(running
669
+ ? enabled !== 'enabled'
670
+ ? { fix: systemctlHint('enable devbridge-runner') }
671
+ : linger === false
672
+ ? { fix: `loginctl enable-linger ${me.user}` }
673
+ : {}
674
+ : {
675
+ fix: systemctlHint('start devbridge-runner'),
676
+ fixMore: systemctlHint('status devbridge-runner # why it stopped'),
677
+ }),
678
+ });
679
+ // …and whether it actually reached DevBridge. The same file `status` reads.
680
+ const status = readStatusFile();
681
+ const live = status !== null &&
682
+ isPidAlive(status.pid) &&
683
+ Date.now() - new Date(status.updatedAt).getTime() < STATUS_FRESH_MS;
684
+ checks.push({
685
+ ok: Boolean(live && status?.connected),
686
+ name: 'connected',
687
+ detail: !live
688
+ ? 'the daemon has not reported in — it is not running, or it just started'
689
+ : status?.connected
690
+ ? `talking to ${status.apiUrl ?? 'DevBridge'}`
691
+ : 'running but NOT connected to DevBridge',
692
+ ...(live && status?.connected
693
+ ? {}
694
+ : {
695
+ fix: systemctlHint('status devbridge-runner'),
696
+ fixMore: 'and check the token: devbridge-runner status',
697
+ }),
698
+ });
699
+ }
700
+ const node = await nodeCheck();
701
+ checks.push({
702
+ ok: node.path !== null && !node.problem,
703
+ name: 'node',
704
+ detail: node.path
705
+ ? `${node.version ?? 'installed'}${node.problem ? ` — ${node.problem}` : ''}`
706
+ : 'not on this user’s PATH',
707
+ ...(node.path && !node.problem
708
+ ? {}
709
+ : {
710
+ fix: `install Node 22 for ${me.user} (a Node installed for another user is not inherited)`,
711
+ }),
712
+ });
713
+ const packageDir = resolveInstalledPackageDir();
714
+ const canUpdate = packageDir !== null && installIsWritable(packageDir);
715
+ checks.push({
716
+ // A source checkout is a legitimate setup with a different update channel,
717
+ // not a fault — calling it «not ready» would cry wolf on the dogfood box.
718
+ ok: canUpdate || packageDir === null,
719
+ name: 'updates',
720
+ detail: canUpdate
721
+ ? 'the «Update runner» button in the dashboard will work'
722
+ : packageDir === null
723
+ ? 'started from a source checkout — updated with git, not from the dashboard'
724
+ : `the package in ${packageDir} belongs to another user, so the dashboard button cannot update it`,
725
+ ...(canUpdate || packageDir === null
726
+ ? {}
727
+ : {
728
+ fix: `sudo -iu ${me.user} npm install -g --ignore-scripts --prefix ~/.local ${'@bridge4dev/runner'}`,
729
+ fixMore: `(reinstalling as ${me.user} is what makes the button work; until then every update is a root command)`,
730
+ }),
731
+ });
732
+ return checks;
733
+ }
734
+ async function agentChecks() {
735
+ const me = runnerIdentity();
736
+ // The probes log a verdict line each, which belongs in the journal, not in
737
+ // the middle of a sheet a person is reading.
738
+ const previousLogLevel = process.env['DEVBRIDGE_RUNNER_LOG'];
739
+ process.env['DEVBRIDGE_RUNNER_LOG'] = 'error';
740
+ const auth = await agentAuthStatuses().finally(() => {
741
+ if (previousLogLevel === undefined)
742
+ delete process.env['DEVBRIDGE_RUNNER_LOG'];
743
+ else
744
+ process.env['DEVBRIDGE_RUNNER_LOG'] = previousLogLevel;
745
+ });
746
+ const checks = [];
747
+ for (const [agent, info] of [
748
+ ['claude', auth.claude],
749
+ ['codex', auth.codex],
750
+ ]) {
751
+ const signedIn = info.status === 'ok';
752
+ const command = agent === 'claude' ? 'claude' : 'codex login';
753
+ checks.push({
754
+ ok: signedIn,
755
+ name: `${agent} login`,
756
+ detail: (info.detail ?? info.status) +
757
+ (info.expiresAt ? ` · until ${info.expiresAt.slice(0, 10)}` : ''),
758
+ ...(signedIn
759
+ ? {}
760
+ : {
761
+ fix: `${me.isRoot ? command : `sudo -iu ${me.user} ${command}`}${agent === 'claude' ? ' then /login' : ''}`,
762
+ }),
763
+ });
764
+ }
765
+ const contour = agentConfigContour(me.home);
766
+ const elsewhere = otherHomeWithAgents(me);
767
+ checks.push({
768
+ ok: contour.claudeDir,
769
+ name: 'claude config',
770
+ detail: contour.claudeDir
771
+ ? `${contour.allowRules === null ? 'settings unreadable' : `${contour.allowRules} allow rules`} · ${contour.commands} commands · plugins: ${contour.plugins ? 'yes' : 'no'}`
772
+ : `nothing in ${me.home} — the agent starts with defaults and NO permission allowlist`,
773
+ ...(contour.claudeDir
774
+ ? {}
775
+ : {
776
+ fix: elsewhere
777
+ ? `set it up as ${me.user}, or copy ${elsewhere}/.claude and ${elsewhere}/.codex into ${me.home}`
778
+ : `run the agent once as ${me.user} and set its permissions there`,
779
+ ...(elsewhere
780
+ ? {
781
+ fixMore: '(a copied login means both accounts share ONE refresh token — a renewal in either signs the other out)',
782
+ }
783
+ : {}),
784
+ }),
785
+ });
786
+ return checks;
787
+ }
788
+ async function projectChecks(target, fix) {
789
+ const me = runnerIdentity();
790
+ const checks = [];
791
+ const access = inspectPath(target);
792
+ if (access.unreachable) {
793
+ const blocked = firstUnreachableAncestor(target) ?? target;
794
+ return [
795
+ {
796
+ ok: false,
797
+ name: 'access',
798
+ detail: `${me.user} is not allowed into ${blocked}`,
799
+ fix: `chmod o+x ${blocked} (or setfacl -m u:${me.user}:x ${blocked})`,
800
+ },
801
+ ];
802
+ }
803
+ if (!access.exists) {
804
+ return [{ ok: false, name: 'access', detail: 'missing on this machine' }];
805
+ }
806
+ const usable = access.readable && access.writable;
807
+ checks.push({
808
+ ok: usable,
809
+ name: 'access',
810
+ detail: usable
811
+ ? `readable and writable${access.ownedByUs ? '' : ` (owner uid ${access.ownerUid})`}`
812
+ : `${access.readable ? 'read-only' : 'not readable'} for ${me.user}`,
813
+ ...(usable
814
+ ? {}
815
+ : { fix: `chown -R ${me.user} ${target} (or setfacl -R -m u:${me.user}:rwX ${target})` }),
816
+ });
817
+ const excused = await hasSafeDirectory(target);
818
+ const gitRefuses = !access.ownedByUs && !excused;
819
+ if (gitRefuses && fix) {
820
+ try {
821
+ await addSafeDirectory(target);
822
+ checks.push({ ok: true, name: 'git', detail: `fixed: ${safeDirectoryCommand(target)}` });
823
+ }
824
+ catch (error) {
825
+ checks.push({
826
+ ok: false,
827
+ name: 'git',
828
+ detail: `could not fix: ${String(error instanceof Error ? error.message : error)}`,
829
+ });
830
+ }
831
+ }
832
+ else {
833
+ checks.push({
834
+ ok: !gitRefuses,
835
+ name: 'git',
836
+ detail: gitRefuses
837
+ ? `refuses this repository — it belongs to uid ${access.ownerUid}, not to ${me.user}`
838
+ : 'ok',
839
+ ...(gitRefuses
840
+ ? {
841
+ fix: safeDirectoryCommand(target),
842
+ fixMore: '(if you hand the directory over with chown instead, add the same line for its previous owner — otherwise THEY lose git here)',
843
+ }
844
+ : {}),
845
+ });
846
+ }
847
+ const docker = await dockerCheck();
848
+ if (docker.path) {
849
+ checks.push({
850
+ ok: !docker.problem,
851
+ name: 'docker',
852
+ detail: docker.problem ?? 'usable by this user',
853
+ ...(docker.problem
854
+ ? { fix: `usermod -aG docker ${me.user} (then restart the runner)` }
855
+ : {}),
856
+ });
857
+ }
858
+ return checks;
859
+ }
860
+ async function reportAgentReadiness(paths, fix) {
861
+ const me = runnerIdentity();
862
+ print('');
863
+ print(`Runs as ${me.user} (uid ${me.uid}), home ${me.home}`);
864
+ if (me.isRoot) {
865
+ // Not a warning — the owner's call. But layer 1 is then the ONLY
866
+ // containment, and saying it once is cheaper than assuming they know.
867
+ print(' running as root: the layer-1 policy is the only containment');
868
+ }
869
+ const sections = [
870
+ ['Runner', await runnerChecks()],
871
+ ['Agent', await agentChecks()],
872
+ ];
873
+ for (const target of paths) {
874
+ sections.push([`Project ${target}`, await projectChecks(target, fix)]);
875
+ }
876
+ const failed = sections.flatMap(([, checks]) => checks).filter((check) => !check.ok);
877
+ print('');
878
+ print(failed.length === 0
879
+ ? 'READY — an agent will work on this machine'
880
+ : `NOT READY — ${failed.length} thing${failed.length === 1 ? '' : 's'} to fix (each one has its command below)`);
881
+ for (const [title, checks] of sections) {
882
+ print('');
883
+ print(title);
884
+ for (const check of checks)
885
+ printCheck(check);
886
+ }
887
+ if (paths.length === 0) {
888
+ print('');
889
+ print('Projects');
890
+ // Silence here would read as «all good» rather than «nothing to check yet».
891
+ print(' no project bound yet — this fills in when one is bound in the dashboard.');
892
+ print(' To check one now: devbridge-runner doctor /path/to/project');
893
+ }
894
+ return failed.length === 0;
895
+ }
896
+ async function cmdDoctor(args) {
897
+ const fix = args.includes('--fix');
898
+ const config = loadConfig();
899
+ print(`devbridge-runner ${RUNNER_VERSION}`);
900
+ print(config ? `Paired with: ${config.server.name} (${config.api.url})` : 'Not paired');
901
+ // Paths given on the command line win; otherwise check what this runner has
902
+ // actually been pointed at, because the person running doctor may not know.
903
+ const givenPaths = args.filter((arg) => arg.startsWith('/'));
904
+ const projectPaths = givenPaths.length > 0 ? givenPaths : knownWorkspacePaths();
905
+ const ready = await reportAgentReadiness(projectPaths, fix);
906
+ const cpuCount = os.cpus().length;
907
+ const quota = cpuQuotaPercent(cpuCount);
908
+ print('');
909
+ print('Machine');
910
+ print(` cpus ${cpuCount}`);
911
+ print(` memory total ${Math.round(os.totalmem() / 1024 / 1024)} MB`);
912
+ print(` memory free ${Math.round(os.freemem() / 1024 / 1024)} MB`);
913
+ // The number that matters: sessions cost ~1.5 GB each once an agent starts a
914
+ // build, so this is the honest ceiling regardless of what the dashboard says.
915
+ const advised = Math.max(1, Math.floor(os.totalmem() / 1024 / 1024 / 1536));
916
+ print(` fits sessions ~${advised} (at ~1.5 GB per session under load)`);
917
+ print('');
918
+ print('Service limits');
919
+ const outdated = limitsOverrideIsOutdated();
920
+ print(` drop-in ${limitsOverridePath()}`);
921
+ print(` version ${outdated ? `MISSING or OLD (want ${LIMITS_VERSION})` : LIMITS_VERSION}`);
922
+ print(` cpu quota ${quota === null ? 'none (fewer than 4 cpus)' : `${quota}%`}`);
923
+ let effective;
924
+ try {
925
+ const { stdout } = await execFileAsync('systemctl', [
926
+ '--user',
927
+ 'show',
928
+ 'devbridge-runner',
929
+ '-p',
930
+ 'CPUQuotaPerSecUSec',
931
+ '-p',
932
+ 'MemoryMax',
933
+ '-p',
934
+ 'MemoryHigh',
935
+ '-p',
936
+ 'OOMPolicy',
937
+ '-p',
938
+ 'NRestarts',
939
+ ], { env: systemdUserEnv() });
940
+ effective = stdout.trim().split('\n').filter(Boolean);
941
+ }
942
+ catch {
943
+ effective = ['(systemctl --user unavailable — is this a source checkout?)'];
944
+ }
945
+ print('');
946
+ print('Effective (systemd)');
947
+ for (const line of effective)
948
+ print(` ${line}`);
949
+ // The two settings that turned one agent's OOM into a dead server.
950
+ const bad = effective.filter((l) => (l.startsWith('OOMPolicy=') && !l.endsWith('=continue')) ||
951
+ (l.startsWith('MemoryMax=') && !l.endsWith('=infinity')) ||
952
+ (l.startsWith('CPUQuotaPerSecUSec=') && l.endsWith('=800ms')));
953
+ if (bad.length > 0) {
954
+ print('');
955
+ print('PROBLEM — these limits kill every session on this machine when one agent runs out of memory:');
956
+ for (const line of bad)
957
+ print(` ${line}`);
958
+ }
959
+ // `systemctl set-property` (the zero-downtime emergency fix) persists into
960
+ // `~/.config/systemd/user.control/`, which OUTRANKS the drop-in we ship. Left
961
+ // in place it silently pins the numbers from whatever the emergency was, on a
962
+ // machine whose core count may since have changed — so say so.
963
+ const controlDir = path.join(os.homedir(), '.config', 'systemd', 'user.control', 'devbridge-runner.service.d');
964
+ try {
965
+ const files = fs.readdirSync(controlDir).filter((f) => f.endsWith('.conf'));
966
+ if (files.length > 0) {
967
+ print('');
968
+ print(`Emergency overrides present (these WIN over the shipped policy): ${controlDir}`);
969
+ for (const file of files)
970
+ print(` ${file}`);
971
+ print(' Remove with: systemctl --user revert devbridge-runner');
972
+ }
973
+ }
974
+ catch {
975
+ /* no emergency overrides — the normal case */
976
+ }
977
+ const oomKills = readOomKills();
978
+ if (oomKills !== null) {
979
+ print('');
980
+ print(`OOM kills in this service's cgroup (cumulative): ${oomKills}`);
981
+ }
982
+ if (LAST_EXIT) {
983
+ print(`Previous process exit: ${LAST_EXIT.kind}${LAST_EXIT.at ? ` at ${LAST_EXIT.at}` : ''}`);
984
+ }
985
+ if (!fix) {
986
+ if (outdated || bad.length > 0) {
987
+ print('');
988
+ print('Run `devbridge-runner doctor --fix` to write the drop-in, then restart the service.');
989
+ process.exit(1);
990
+ }
991
+ // A readiness problem is a real finding too: exiting 0 over «the agent has
992
+ // no login here» is how the installing agent reports success on a server
993
+ // where nothing will run.
994
+ if (!ready)
995
+ process.exit(1);
996
+ return;
997
+ }
998
+ writeLimitsOverride(true);
999
+ print('');
1000
+ print(`Wrote ${limitsOverridePath()}`);
1001
+ try {
1002
+ await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
1003
+ print('systemctl --user daemon-reload — done.');
1004
+ print('Restart when sessions are idle: systemctl --user restart devbridge-runner');
1005
+ }
1006
+ catch (error) {
1007
+ fail(`daemon-reload failed: ${String(error instanceof Error ? error.message : error)}`);
1008
+ }
1009
+ }
315
1010
  // ─── set-token ───────────────────────────────────────────────────────
316
1011
  function cmdSetToken(args) {
317
1012
  const token = args[0];
@@ -330,9 +1025,19 @@ Usage:
330
1025
  devbridge-runner install-service install + start systemd user service
331
1026
  devbridge-runner status connection status (exit 0 = connected)
332
1027
  devbridge-runner set-token <dbr_token> store a rotated runner token
1028
+ devbridge-runner doctor [--fix] [path...] will an agent work here? logins, settings,
1029
+ project permissions, resource limits
333
1030
  `;
334
1031
  async function main() {
1032
+ // Before anything looks for an agent CLI. Append-only, so a service that can
1033
+ // already find its tools keeps finding them (see ensureAgentPath).
1034
+ const addedToPath = ensureAgentPath();
335
1035
  const [command, ...args] = process.argv.slice(2);
1036
+ // Said once, and only when it actually changed something: on a machine where
1037
+ // the agent CLIs were already findable this is silent.
1038
+ if (addedToPath.length > 0 && command === 'daemon') {
1039
+ log.info('runner: extended PATH so the agent CLIs are findable', { added: addedToPath });
1040
+ }
336
1041
  switch (command) {
337
1042
  case 'pair':
338
1043
  return cmdPair(args);
@@ -344,6 +1049,8 @@ async function main() {
344
1049
  return cmdInstallService();
345
1050
  case 'set-token':
346
1051
  return cmdSetToken(args);
1052
+ case 'doctor':
1053
+ return cmdDoctor(args);
347
1054
  case '--version':
348
1055
  case 'version':
349
1056
  print(RUNNER_VERSION);