@bridge4dev/runner 0.22.1 → 0.27.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/index.js CHANGED
@@ -10,13 +10,16 @@ import { CodexAdapter } from './adapters/codex.js';
10
10
  import { ensureCodexHome } from './adapters/codex-home.js';
11
11
  import { loadConfig, requireConfig, saveConfig } from './config.js';
12
12
  import { log } from './log.js';
13
- import { isSupervisedProcess, resolveInstalledPackageDir } from './self-update.js';
13
+ import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
14
+ import { applyStoredClaudeToken } from './agent-auth.js';
14
15
  import { Supervisor } from './supervisor.js';
15
16
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
16
17
  import { RunnerWsClient } from './ws-client.js';
17
18
  import { RUNNER_VERSION } from './version.js';
18
19
  import { buildUnit, cpuQuotaPercent, limitsOverrideIsOutdated, limitsOverridePath, unitExecTarget, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
19
20
  import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
21
+ import { agentAuthStatuses } from './auth-relay.js';
22
+ import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, systemdUserEnv, } from './environment.js';
20
23
  import { mcpConfigDir } from './paths.js';
21
24
  const execFileAsync = promisify(execFile);
22
25
  /**
@@ -60,7 +63,8 @@ function installedAgents() {
60
63
  * instead of a button that would fail on tap.
61
64
  */
62
65
  function selfUpdatable() {
63
- return resolveInstalledPackageDir() !== null && isSupervisedProcess();
66
+ const packageDir = resolveInstalledPackageDir();
67
+ return packageDir !== null && isSupervisedProcess() && installIsWritable(packageDir);
64
68
  }
65
69
  /**
66
70
  * Why this runner cannot replace itself — so the card can SAY it.
@@ -71,10 +75,15 @@ function selfUpdatable() {
71
75
  * only the runner knows how it was started.
72
76
  */
73
77
  function selfUpdateBlockedReason() {
74
- if (resolveInstalledPackageDir() === null)
78
+ const packageDir = resolveInstalledPackageDir();
79
+ if (packageDir === null)
75
80
  return 'source-checkout';
76
81
  if (!isSupervisedProcess())
77
82
  return 'unsupervised';
83
+ // Installed by one user, run by another: npm would fail halfway through with
84
+ // EACCES. Reported so the card shows the command instead of a button.
85
+ if (!installIsWritable(packageDir))
86
+ return 'not-writable';
78
87
  return null;
79
88
  }
80
89
  function hasExecutable(name) {
@@ -95,8 +104,13 @@ function hasExecutable(name) {
95
104
  * older runner drops a frame it cannot parse without ever replying, so a
96
105
  * command it does not know would just hang until the gateway timeout.
97
106
  */
98
- function runnerCapabilities() {
107
+ function runnerCapabilities(apiUrlOverride) {
99
108
  const config = loadConfig();
109
+ // At PAIR time there is no config yet, so the API this runner is about to
110
+ // belong to has to be passed in — otherwise the very first `hello` (the one
111
+ // that creates the server record) would omit the update command, and the
112
+ // dashboard would show a stale one until the daemon reconnected.
113
+ const apiUrl = apiUrlOverride ?? config?.api.url;
100
114
  const localLimit = config?.limits?.max_sessions;
101
115
  // Session 14: the machine owner's veto. Default on — the safety of the
102
116
  // feature is that a DevBridge manager approves every command first — but the
@@ -119,6 +133,45 @@ function runnerCapabilities() {
119
133
  ...(selfUpdatable()
120
134
  ? { selfUpdate: true }
121
135
  : { selfUpdateBlocked: selfUpdateBlockedReason() ?? 'unsupervised' }),
136
+ /**
137
+ * Which OS user this daemon runs as (0.24.0).
138
+ *
139
+ * The card needs it to write a command that will actually work: an update
140
+ * installed by root has to be restarted as THIS user, and «run it as root»
141
+ * is only half the instruction without a name to restart under.
142
+ */
143
+ runnerUser: runnerIdentity().user,
144
+ /**
145
+ * Which agent CLIs are actually on this user's PATH (0.27.0).
146
+ *
147
+ * Different question from `agents` above, and the difference cost a
148
+ * support round: `agents` says which agents can RUN sessions (Claude
149
+ * always can — the SDK bundles its own binary), while signing in needs the
150
+ * standalone CLI to exist for this user. On a dedicated-user install it
151
+ * routinely does not, and the sign-in button then failed with an error
152
+ * about `script`, about a machine whose real problem was that nobody had
153
+ * installed `claude` for that user at all.
154
+ */
155
+ agentClis: {
156
+ claude: hasExecutable('claude'),
157
+ codex: hasExecutable('codex'),
158
+ },
159
+ /**
160
+ * Where npm put this package, and the command that updates it here
161
+ * (0.27.0).
162
+ *
163
+ * The dashboard used to assemble the update command itself and then patch
164
+ * it with string surgery for the dedicated-user case. It cannot: only this
165
+ * process knows the prefix it was installed into, and `npm install -g`
166
+ * without that prefix is precisely the EACCES the owner pasted. So the
167
+ * machine states its own command and the panel just shows it.
168
+ */
169
+ ...(installPrefixFor() ? { npmPrefix: installPrefixFor() } : {}),
170
+ ...(apiUrl
171
+ ? {
172
+ updateCommand: manualUpdateCommand(`${apiUrl.replace(/\/$/, '')}/api/v1/dev-setup/runner.tgz`),
173
+ }
174
+ : {}),
122
175
  /**
123
176
  * A stricter ceiling set on the machine itself (layer 1). Reported so the
124
177
  * dashboard can explain why raising the number there changed nothing.
@@ -290,7 +343,7 @@ async function cmdPair(args) {
290
343
  name,
291
344
  runnerVersion: RUNNER_VERSION,
292
345
  osInfo: `${os.type()} ${os.release()} ${os.arch()}`.slice(0, 200),
293
- capabilities: runnerCapabilities(),
346
+ capabilities: runnerCapabilities(apiUrl),
294
347
  }),
295
348
  });
296
349
  const body = (await response.json().catch(() => null));
@@ -360,7 +413,10 @@ async function repairResourceLimits() {
360
413
  path: limitsOverridePath(),
361
414
  version: LIMITS_VERSION,
362
415
  });
363
- await execFileAsync('systemctl', ['--user', 'daemon-reload'], { timeout: 15_000 });
416
+ await execFileAsync('systemctl', ['--user', 'daemon-reload'], {
417
+ timeout: 15_000,
418
+ env: systemdUserEnv(),
419
+ });
364
420
  // Deliberately no restart: `daemon-reload` alone is enough for these
365
421
  // directives (verified live — OOMPolicy went stop→continue and MemoryMax
366
422
  // 2G→infinity with the PID unchanged), and restarting here would park every
@@ -410,6 +466,12 @@ async function cmdDaemon() {
410
466
  log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
411
467
  sweepOrphanedMcpConfigs();
412
468
  await repairResourceLimits();
469
+ // A Claude token this runner captured through the sign-in relay. Applied
470
+ // BEFORE any adapter exists, because `scrubbedEnv()` copies it out of this
471
+ // process's environment for every session it starts.
472
+ if (applyStoredClaudeToken()) {
473
+ log.info('daemon: using the Claude token stored on this server');
474
+ }
413
475
  const agents = installedAgents();
414
476
  const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
415
477
  const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
@@ -576,8 +638,10 @@ async function cmdInstallService() {
576
638
  print(`note: the service runs ${exec.execStart} directly — re-run install-service after reinstalling the package.`);
577
639
  }
578
640
  try {
579
- await execFileAsync('systemctl', ['--user', 'daemon-reload']);
580
- await execFileAsync('systemctl', ['--user', 'enable', '--now', 'devbridge-runner']);
641
+ await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
642
+ await execFileAsync('systemctl', ['--user', 'enable', '--now', 'devbridge-runner'], {
643
+ env: systemdUserEnv(),
644
+ });
581
645
  print('Service enabled and started (systemctl --user).');
582
646
  }
583
647
  catch (error) {
@@ -593,21 +657,388 @@ async function cmdInstallService() {
593
657
  }
594
658
  print('Verify with: devbridge-runner status');
595
659
  }
596
- // ─── doctor ──────────────────────────────────────────────────────────
597
- /**
598
- * The one command that answers «is this machine set up to run the sessions it
599
- * says it can run».
600
- *
601
- * It exists because of what the 2026-07-30 incident cost to diagnose: the
602
- * runner's own systemd limits were the cause, they were invisible from
603
- * DevBridge, and the machine could not be reached. Everything printed here was
604
- * needed to reach that answer and none of it was available in one place.
605
- */
660
+ function printCheck(check) {
661
+ print(` ${check.ok ? '✔' : '✘'} ${check.name.padEnd(15)} ${check.detail}`);
662
+ if (check.fix)
663
+ print(` → ${check.fix}`);
664
+ if (check.fixMore)
665
+ print(` ${check.fixMore}`);
666
+ }
667
+ /** One property of the user service, or null when systemd cannot answer. */
668
+ async function systemctlProperty(name) {
669
+ try {
670
+ const { stdout } = await execFileAsync('systemctl', ['--user', 'show', 'devbridge-runner', '-p', name, '--value'], { timeout: 10_000, env: systemdUserEnv() });
671
+ const value = stdout.trim();
672
+ return value.length > 0 ? value : null;
673
+ }
674
+ catch {
675
+ return null;
676
+ }
677
+ }
678
+ async function runnerChecks() {
679
+ const me = runnerIdentity();
680
+ const checks = [];
681
+ const busReachable = await systemdUserBusReachable();
682
+ const linger = await lingerEnabled();
683
+ if (!busReachable) {
684
+ checks.push({
685
+ ok: false,
686
+ name: 'service',
687
+ detail: 'systemd user session NOT reachable',
688
+ // `systemctl --user` prints «Failed to connect to bus» and exits 0, so a
689
+ // health check reads that as success. Give the form that works.
690
+ fix: systemctlHint('status devbridge-runner'),
691
+ });
692
+ }
693
+ else {
694
+ // The bus answering says nothing about the service. Reporting READY over a
695
+ // stopped daemon is worse than having no acceptance at all — an acceptance
696
+ // that lies is what sends somebody away from a server that does not work.
697
+ const state = await systemctlProperty('ActiveState');
698
+ const enabled = await systemctlProperty('UnitFileState');
699
+ const running = state === 'active';
700
+ // Three separate promises, and the verdict has to fail on any of them.
701
+ // Until 0.27.0 only the first counted: a service that was running but
702
+ // would die at the next logout (linger off) or never come back after a
703
+ // reboot (not enabled) still printed ✔ and still said READY, with the
704
+ // `loginctl enable-linger` line sitting UNDER the tick as if it were
705
+ // advice. The whole point of an acceptance sheet is that a green one means
706
+ // walk away — so a runner that stops when its user logs out is a red line.
707
+ const durable = running && enabled === 'enabled' && linger !== false;
708
+ checks.push({
709
+ ok: durable,
710
+ name: 'service',
711
+ detail: running
712
+ ? `running${enabled === 'enabled' ? ', starts on boot' : ' — but NOT enabled: it will not come back after a reboot'}` +
713
+ (linger === true
714
+ ? ', survives logout'
715
+ : linger === false
716
+ ? ', but linger is OFF: it stops when this user logs out'
717
+ : ', linger state unknown')
718
+ : `NOT running (${state ?? 'unknown'})`,
719
+ ...(running
720
+ ? enabled !== 'enabled'
721
+ ? { fix: systemctlHint('enable devbridge-runner') }
722
+ : linger === false
723
+ ? { fix: `loginctl enable-linger ${me.user}` }
724
+ : {}
725
+ : {
726
+ fix: systemctlHint('start devbridge-runner'),
727
+ fixMore: systemctlHint('status devbridge-runner # why it stopped'),
728
+ }),
729
+ });
730
+ // …and whether it actually reached DevBridge. The same file `status` reads.
731
+ const status = readStatusFile();
732
+ const live = status !== null &&
733
+ isPidAlive(status.pid) &&
734
+ Date.now() - new Date(status.updatedAt).getTime() < STATUS_FRESH_MS;
735
+ checks.push({
736
+ ok: Boolean(live && status?.connected),
737
+ name: 'connected',
738
+ detail: !live
739
+ ? 'the daemon has not reported in — it is not running, or it just started'
740
+ : status?.connected
741
+ ? `talking to ${status.apiUrl ?? 'DevBridge'}`
742
+ : 'running but NOT connected to DevBridge',
743
+ ...(live && status?.connected
744
+ ? {}
745
+ : {
746
+ fix: systemctlHint('status devbridge-runner'),
747
+ fixMore: 'and check the token: devbridge-runner status',
748
+ }),
749
+ });
750
+ }
751
+ const node = await nodeCheck();
752
+ checks.push({
753
+ ok: node.path !== null && !node.problem,
754
+ name: 'node',
755
+ detail: node.path
756
+ ? `${node.version ?? 'installed'}${node.problem ? ` — ${node.problem}` : ''}`
757
+ : 'not on this user’s PATH',
758
+ ...(node.path && !node.problem
759
+ ? {}
760
+ : {
761
+ fix: `install Node 22 for ${me.user} (a Node installed for another user is not inherited)`,
762
+ }),
763
+ });
764
+ const packageDir = resolveInstalledPackageDir();
765
+ const canUpdate = packageDir !== null && installIsWritable(packageDir);
766
+ checks.push({
767
+ // A source checkout is a legitimate setup with a different update channel,
768
+ // not a fault — calling it «not ready» would cry wolf on the dogfood box.
769
+ ok: canUpdate || packageDir === null,
770
+ name: 'updates',
771
+ detail: canUpdate
772
+ ? 'the «Update runner» button in the dashboard will work'
773
+ : packageDir === null
774
+ ? 'started from a source checkout — updated with git, not from the dashboard'
775
+ : `the package in ${packageDir} belongs to another user, so the dashboard button cannot update it`,
776
+ ...(canUpdate || packageDir === null
777
+ ? {}
778
+ : {
779
+ // NOT `--prefix ~/.local`, which is what this line used to say. The
780
+ // tilde is expanded by the CALLING shell, so run as root it aimed at
781
+ // /root/.local — a directory the daemon's user cannot write — and
782
+ // the install died naming a home nobody had chosen. `sudo -iu` hands
783
+ // the string to the TARGET user's login shell, so `$HOME` is theirs.
784
+ fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --loglevel=error @bridge4dev/runner'`,
785
+ fixMore: `(the \`npm config set prefix\` half is what keeps the button working: without it every LATER update aims at the system prefix again and fails with EACCES)`,
786
+ }),
787
+ });
788
+ // The relay that signs an agent in runs the CLI on a pty, and util-linux
789
+ // `script` is what allocates it. Missing on minimal images, and its absence
790
+ // used to surface as «claude login exited before printing a sign-in URL».
791
+ const hasScript = hasExecutable('script');
792
+ checks.push({
793
+ ok: hasScript,
794
+ name: 'sign-in relay',
795
+ detail: hasScript
796
+ ? 'ready (util-linux `script` present)'
797
+ : '`script` (util-linux) is missing — the dashboard sign-in button cannot run',
798
+ ...(hasScript ? {} : { fix: 'apt-get install -y bsdextrautils util-linux' }),
799
+ });
800
+ return checks;
801
+ }
802
+ async function agentChecks() {
803
+ const me = runnerIdentity();
804
+ // The probes log a verdict line each, which belongs in the journal, not in
805
+ // the middle of a sheet a person is reading.
806
+ const previousLogLevel = process.env['DEVBRIDGE_RUNNER_LOG'];
807
+ process.env['DEVBRIDGE_RUNNER_LOG'] = 'error';
808
+ const auth = await agentAuthStatuses().finally(() => {
809
+ if (previousLogLevel === undefined)
810
+ delete process.env['DEVBRIDGE_RUNNER_LOG'];
811
+ else
812
+ process.env['DEVBRIDGE_RUNNER_LOG'] = previousLogLevel;
813
+ });
814
+ const checks = [];
815
+ // Does the CLI exist for THIS user, before asking whether it is signed in?
816
+ // «not signed in» over a machine that has no `claude` at all sends the
817
+ // reader to a login screen for a command that does not exist — and doctor's
818
+ // own remedy (`sudo -iu <user> claude`) was then `command not found`. That
819
+ // is the state a dedicated-user install leaves behind by default.
820
+ const cliPresent = {
821
+ claude: hasExecutable('claude'),
822
+ codex: hasExecutable('codex'),
823
+ };
824
+ checks.push({
825
+ ok: cliPresent.claude,
826
+ name: 'claude cli',
827
+ detail: cliPresent.claude
828
+ ? `on ${me.user}'s PATH`
829
+ : `not installed for ${me.user} — sessions still run (the SDK bundles its own), but signing in needs the CLI`,
830
+ ...(cliPresent.claude
831
+ ? {}
832
+ : {
833
+ fix: `sudo -iu ${me.user} sh -lc 'curl -fsSL https://claude.ai/install.sh | bash'`,
834
+ }),
835
+ });
836
+ // Codex is optional: plenty of machines only ever run Claude sessions, and a
837
+ // red line for an agent nobody uses is noise that trains people to ignore
838
+ // the sheet. Reported, not judged.
839
+ checks.push({
840
+ ok: true,
841
+ name: 'codex cli',
842
+ detail: cliPresent.codex ? `on ${me.user}'s PATH` : `not installed for ${me.user} (optional)`,
843
+ ...(cliPresent.codex
844
+ ? {}
845
+ : {
846
+ fix: `sudo -iu ${me.user} sh -lc 'npm install -g @openai/codex' # only if you use Codex`,
847
+ }),
848
+ });
849
+ for (const [agent, info] of [
850
+ ['claude', auth.claude],
851
+ ['codex', auth.codex],
852
+ ]) {
853
+ const signedIn = info.status === 'ok';
854
+ checks.push({
855
+ ok: signedIn,
856
+ name: `${agent} login`,
857
+ detail: (info.detail ?? info.status) +
858
+ (info.expiresAt ? ` · until ${info.expiresAt.slice(0, 10)}` : ''),
859
+ ...(signedIn
860
+ ? {}
861
+ : {
862
+ // The dashboard button FIRST. It is the product's own path, it
863
+ // needs no shell on the server, and an installing agent cannot
864
+ // perform an interactive OAuth login anyway — so telling it to
865
+ // «run claude and do /login» is telling it to stop. That is
866
+ // exactly where the last three installs stopped.
867
+ fix: `Dashboard → Development → this server → AGENTS → «Sign in» next to ${agent === 'claude' ? 'Claude Code' : 'Codex'}`,
868
+ fixMore: cliPresent[agent]
869
+ ? `or on the server: ${me.isRoot ? '' : `sudo -iu ${me.user} `}${agent === 'claude' ? 'claude auth login' : 'codex login'}`
870
+ : `(install the CLI first — see the «${agent} cli» line above)`,
871
+ }),
872
+ });
873
+ }
874
+ const contour = agentConfigContour(me.home);
875
+ const elsewhere = otherHomeWithAgents(me);
876
+ // MCP servers a session will really see. Only user scope survives a session
877
+ // worktree, and «configured but in the wrong scope» looks identical to
878
+ // «working» from anywhere except inside a session.
879
+ const mcpHidden = contour.mcpUserScope === 0 && contour.mcpProjectScope > 0;
880
+ checks.push({
881
+ ok: !mcpHidden,
882
+ name: 'mcp servers',
883
+ detail: mcpHidden
884
+ ? `${contour.mcpProjectScope} configured, but all per-directory — a session works in its own worktree and will see NONE`
885
+ : contour.mcpUserScope > 0
886
+ ? `${contour.mcpUserScope} available to every session`
887
+ : 'none configured (the DevBridge server is injected per session regardless)',
888
+ ...(mcpHidden
889
+ ? {
890
+ fix: `sudo -iu ${me.user} claude mcp add --scope user <name> … # re-add at user scope`,
891
+ fixMore: '(`claude mcp add` defaults to the current directory’s scope, which no session shares)',
892
+ }
893
+ : {}),
894
+ });
895
+ checks.push({
896
+ ok: contour.claudeDir,
897
+ name: 'claude config',
898
+ detail: contour.claudeDir
899
+ ? `${contour.allowRules === null ? 'settings unreadable' : `${contour.allowRules} allow rules`} · ${contour.commands} commands · plugins: ${contour.plugins ? 'yes' : 'no'}`
900
+ : `nothing in ${me.home} — the agent starts with defaults and NO permission allowlist`,
901
+ ...(contour.claudeDir
902
+ ? {}
903
+ : {
904
+ fix: elsewhere
905
+ ? `set it up as ${me.user}, or copy ${elsewhere}/.claude and ${elsewhere}/.codex into ${me.home}`
906
+ : `run the agent once as ${me.user} and set its permissions there`,
907
+ ...(elsewhere
908
+ ? {
909
+ fixMore: '(a copied login means both accounts share ONE refresh token — a renewal in either signs the other out)',
910
+ }
911
+ : {}),
912
+ }),
913
+ });
914
+ return checks;
915
+ }
916
+ async function projectChecks(target, fix) {
917
+ const me = runnerIdentity();
918
+ const checks = [];
919
+ const access = inspectPath(target);
920
+ if (access.unreachable) {
921
+ const blocked = firstUnreachableAncestor(target) ?? target;
922
+ return [
923
+ {
924
+ ok: false,
925
+ name: 'access',
926
+ detail: `${me.user} is not allowed into ${blocked}`,
927
+ fix: `chmod o+x ${blocked} (or setfacl -m u:${me.user}:x ${blocked})`,
928
+ },
929
+ ];
930
+ }
931
+ if (!access.exists) {
932
+ return [{ ok: false, name: 'access', detail: 'missing on this machine' }];
933
+ }
934
+ const usable = access.readable && access.writable;
935
+ checks.push({
936
+ ok: usable,
937
+ name: 'access',
938
+ detail: usable
939
+ ? `readable and writable${access.ownedByUs ? '' : ` (owner uid ${access.ownerUid})`}`
940
+ : `${access.readable ? 'read-only' : 'not readable'} for ${me.user}`,
941
+ ...(usable
942
+ ? {}
943
+ : { fix: `chown -R ${me.user} ${target} (or setfacl -R -m u:${me.user}:rwX ${target})` }),
944
+ });
945
+ const excused = await hasSafeDirectory(target);
946
+ const gitRefuses = !access.ownedByUs && !excused;
947
+ if (gitRefuses && fix) {
948
+ try {
949
+ await addSafeDirectory(target);
950
+ checks.push({ ok: true, name: 'git', detail: `fixed: ${safeDirectoryCommand(target)}` });
951
+ }
952
+ catch (error) {
953
+ checks.push({
954
+ ok: false,
955
+ name: 'git',
956
+ detail: `could not fix: ${String(error instanceof Error ? error.message : error)}`,
957
+ });
958
+ }
959
+ }
960
+ else {
961
+ checks.push({
962
+ ok: !gitRefuses,
963
+ name: 'git',
964
+ detail: gitRefuses
965
+ ? `refuses this repository — it belongs to uid ${access.ownerUid}, not to ${me.user}`
966
+ : 'ok',
967
+ ...(gitRefuses
968
+ ? {
969
+ fix: safeDirectoryCommand(target),
970
+ fixMore: '(if you hand the directory over with chown instead, add the same line for its previous owner — otherwise THEY lose git here)',
971
+ }
972
+ : {}),
973
+ });
974
+ }
975
+ const docker = await dockerCheck();
976
+ if (docker.path) {
977
+ checks.push({
978
+ ok: !docker.problem,
979
+ name: 'docker',
980
+ detail: docker.problem ?? 'usable by this user',
981
+ ...(docker.problem
982
+ ? {
983
+ fix: `usermod -aG docker ${me.user}`,
984
+ // Restarting the unit is NOT enough and this cost a diagnosis:
985
+ // supplementary groups are fixed when logind creates
986
+ // user@<uid>.service, so the daemon keeps the old set until the
987
+ // whole user manager restarts. `id ${me.user}` then shows the new
988
+ // group while /proc/<pid>/status still shows the old one.
989
+ fixMore: `systemctl restart user@${me.uid}.service # the unit alone keeps the old group set`,
990
+ }
991
+ : {}),
992
+ });
993
+ }
994
+ return checks;
995
+ }
996
+ async function reportAgentReadiness(paths, fix) {
997
+ const me = runnerIdentity();
998
+ print('');
999
+ print(`Runs as ${me.user} (uid ${me.uid}), home ${me.home}`);
1000
+ if (me.isRoot) {
1001
+ // Not a warning — the owner's call. But layer 1 is then the ONLY
1002
+ // containment, and saying it once is cheaper than assuming they know.
1003
+ print(' running as root: the layer-1 policy is the only containment');
1004
+ }
1005
+ const sections = [
1006
+ ['Runner', await runnerChecks()],
1007
+ ['Agent', await agentChecks()],
1008
+ ];
1009
+ for (const target of paths) {
1010
+ sections.push([`Project ${target}`, await projectChecks(target, fix)]);
1011
+ }
1012
+ const failed = sections.flatMap(([, checks]) => checks).filter((check) => !check.ok);
1013
+ print('');
1014
+ print(failed.length === 0
1015
+ ? 'READY — an agent will work on this machine'
1016
+ : `NOT READY — ${failed.length} thing${failed.length === 1 ? '' : 's'} to fix (each one has its command below)`);
1017
+ for (const [title, checks] of sections) {
1018
+ print('');
1019
+ print(title);
1020
+ for (const check of checks)
1021
+ printCheck(check);
1022
+ }
1023
+ if (paths.length === 0) {
1024
+ print('');
1025
+ print('Projects');
1026
+ // Silence here would read as «all good» rather than «nothing to check yet».
1027
+ print(' no project bound yet — this fills in when one is bound in the dashboard.');
1028
+ print(' To check one now: devbridge-runner doctor /path/to/project');
1029
+ }
1030
+ return failed.length === 0;
1031
+ }
606
1032
  async function cmdDoctor(args) {
607
1033
  const fix = args.includes('--fix');
608
1034
  const config = loadConfig();
609
1035
  print(`devbridge-runner ${RUNNER_VERSION}`);
610
1036
  print(config ? `Paired with: ${config.server.name} (${config.api.url})` : 'Not paired');
1037
+ // Paths given on the command line win; otherwise check what this runner has
1038
+ // actually been pointed at, because the person running doctor may not know.
1039
+ const givenPaths = args.filter((arg) => arg.startsWith('/'));
1040
+ const projectPaths = givenPaths.length > 0 ? givenPaths : knownWorkspacePaths();
1041
+ const ready = await reportAgentReadiness(projectPaths, fix);
611
1042
  const cpuCount = os.cpus().length;
612
1043
  const quota = cpuQuotaPercent(cpuCount);
613
1044
  print('');
@@ -641,7 +1072,7 @@ async function cmdDoctor(args) {
641
1072
  'OOMPolicy',
642
1073
  '-p',
643
1074
  'NRestarts',
644
- ]);
1075
+ ], { env: systemdUserEnv() });
645
1076
  effective = stdout.trim().split('\n').filter(Boolean);
646
1077
  }
647
1078
  catch {
@@ -693,13 +1124,18 @@ async function cmdDoctor(args) {
693
1124
  print('Run `devbridge-runner doctor --fix` to write the drop-in, then restart the service.');
694
1125
  process.exit(1);
695
1126
  }
1127
+ // A readiness problem is a real finding too: exiting 0 over «the agent has
1128
+ // no login here» is how the installing agent reports success on a server
1129
+ // where nothing will run.
1130
+ if (!ready)
1131
+ process.exit(1);
696
1132
  return;
697
1133
  }
698
1134
  writeLimitsOverride(true);
699
1135
  print('');
700
1136
  print(`Wrote ${limitsOverridePath()}`);
701
1137
  try {
702
- await execFileAsync('systemctl', ['--user', 'daemon-reload']);
1138
+ await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
703
1139
  print('systemctl --user daemon-reload — done.');
704
1140
  print('Restart when sessions are idle: systemctl --user restart devbridge-runner');
705
1141
  }
@@ -725,10 +1161,19 @@ Usage:
725
1161
  devbridge-runner install-service install + start systemd user service
726
1162
  devbridge-runner status connection status (exit 0 = connected)
727
1163
  devbridge-runner set-token <dbr_token> store a rotated runner token
728
- devbridge-runner doctor [--fix] report (and repair) resource limits
1164
+ devbridge-runner doctor [--fix] [path...] will an agent work here? logins, settings,
1165
+ project permissions, resource limits
729
1166
  `;
730
1167
  async function main() {
1168
+ // Before anything looks for an agent CLI. Append-only, so a service that can
1169
+ // already find its tools keeps finding them (see ensureAgentPath).
1170
+ const addedToPath = ensureAgentPath();
731
1171
  const [command, ...args] = process.argv.slice(2);
1172
+ // Said once, and only when it actually changed something: on a machine where
1173
+ // the agent CLIs were already findable this is silent.
1174
+ if (addedToPath.length > 0 && command === 'daemon') {
1175
+ log.info('runner: extended PATH so the agent CLIs are findable', { added: addedToPath });
1176
+ }
732
1177
  switch (command) {
733
1178
  case 'pair':
734
1179
  return cmdPair(args);
package/dist/paths.d.ts CHANGED
@@ -11,6 +11,15 @@ export declare function configFilePath(): string;
11
11
  export declare function statusFilePath(): string;
12
12
  export declare function journalDir(): string;
13
13
  export declare function worktreesDir(): string;
14
+ /**
15
+ * Project directories this runner has been pointed at.
16
+ *
17
+ * Kept so `devbridge-runner doctor` can check the permissions that actually
18
+ * matter on this machine without being told the paths — the runner learns them
19
+ * from the API (binding a project, starting a session) and the person running
20
+ * doctor may have no idea which directories are bound.
21
+ */
22
+ export declare function knownWorkspacesPath(): string;
14
23
  /** Session 14: the single preview checkout per repository. */
15
24
  export declare function previewsDir(): string;
16
25
  /**
package/dist/paths.js CHANGED
@@ -39,6 +39,17 @@ export function journalDir() {
39
39
  export function worktreesDir() {
40
40
  return path.join(stateDir(), 'worktrees');
41
41
  }
42
+ /**
43
+ * Project directories this runner has been pointed at.
44
+ *
45
+ * Kept so `devbridge-runner doctor` can check the permissions that actually
46
+ * matter on this machine without being told the paths — the runner learns them
47
+ * from the API (binding a project, starting a session) and the person running
48
+ * doctor may have no idea which directories are bound.
49
+ */
50
+ export function knownWorkspacesPath() {
51
+ return path.join(stateDir(), 'workspaces.json');
52
+ }
42
53
  /** Session 14: the single preview checkout per repository. */
43
54
  export function previewsDir() {
44
55
  return path.join(stateDir(), 'previews');