@bridge4dev/runner 0.22.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10,13 +10,15 @@ 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, isSupervisedProcess, resolveInstalledPackageDir, } from './self-update.js';
14
14
  import { Supervisor } from './supervisor.js';
15
15
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
16
16
  import { RunnerWsClient } from './ws-client.js';
17
17
  import { RUNNER_VERSION } from './version.js';
18
18
  import { buildUnit, cpuQuotaPercent, limitsOverrideIsOutdated, limitsOverridePath, unitExecTarget, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
19
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';
20
22
  import { mcpConfigDir } from './paths.js';
21
23
  const execFileAsync = promisify(execFile);
22
24
  /**
@@ -60,7 +62,8 @@ function installedAgents() {
60
62
  * instead of a button that would fail on tap.
61
63
  */
62
64
  function selfUpdatable() {
63
- return resolveInstalledPackageDir() !== null && isSupervisedProcess();
65
+ const packageDir = resolveInstalledPackageDir();
66
+ return packageDir !== null && isSupervisedProcess() && installIsWritable(packageDir);
64
67
  }
65
68
  /**
66
69
  * Why this runner cannot replace itself — so the card can SAY it.
@@ -71,10 +74,15 @@ function selfUpdatable() {
71
74
  * only the runner knows how it was started.
72
75
  */
73
76
  function selfUpdateBlockedReason() {
74
- if (resolveInstalledPackageDir() === null)
77
+ const packageDir = resolveInstalledPackageDir();
78
+ if (packageDir === null)
75
79
  return 'source-checkout';
76
80
  if (!isSupervisedProcess())
77
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';
78
86
  return null;
79
87
  }
80
88
  function hasExecutable(name) {
@@ -119,6 +127,14 @@ function runnerCapabilities() {
119
127
  ...(selfUpdatable()
120
128
  ? { selfUpdate: true }
121
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,
122
138
  /**
123
139
  * A stricter ceiling set on the machine itself (layer 1). Reported so the
124
140
  * dashboard can explain why raising the number there changed nothing.
@@ -360,7 +376,10 @@ async function repairResourceLimits() {
360
376
  path: limitsOverridePath(),
361
377
  version: LIMITS_VERSION,
362
378
  });
363
- await execFileAsync('systemctl', ['--user', 'daemon-reload'], { timeout: 15_000 });
379
+ await execFileAsync('systemctl', ['--user', 'daemon-reload'], {
380
+ timeout: 15_000,
381
+ env: systemdUserEnv(),
382
+ });
364
383
  // Deliberately no restart: `daemon-reload` alone is enough for these
365
384
  // directives (verified live — OOMPolicy went stop→continue and MemoryMax
366
385
  // 2G→infinity with the PID unchanged), and restarting here would park every
@@ -576,8 +595,10 @@ async function cmdInstallService() {
576
595
  print(`note: the service runs ${exec.execStart} directly — re-run install-service after reinstalling the package.`);
577
596
  }
578
597
  try {
579
- await execFileAsync('systemctl', ['--user', 'daemon-reload']);
580
- 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
+ });
581
602
  print('Service enabled and started (systemctl --user).');
582
603
  }
583
604
  catch (error) {
@@ -593,21 +614,295 @@ async function cmdInstallService() {
593
614
  }
594
615
  print('Verify with: devbridge-runner status');
595
616
  }
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
- */
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
+ }
606
896
  async function cmdDoctor(args) {
607
897
  const fix = args.includes('--fix');
608
898
  const config = loadConfig();
609
899
  print(`devbridge-runner ${RUNNER_VERSION}`);
610
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);
611
906
  const cpuCount = os.cpus().length;
612
907
  const quota = cpuQuotaPercent(cpuCount);
613
908
  print('');
@@ -641,7 +936,7 @@ async function cmdDoctor(args) {
641
936
  'OOMPolicy',
642
937
  '-p',
643
938
  'NRestarts',
644
- ]);
939
+ ], { env: systemdUserEnv() });
645
940
  effective = stdout.trim().split('\n').filter(Boolean);
646
941
  }
647
942
  catch {
@@ -693,13 +988,18 @@ async function cmdDoctor(args) {
693
988
  print('Run `devbridge-runner doctor --fix` to write the drop-in, then restart the service.');
694
989
  process.exit(1);
695
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);
696
996
  return;
697
997
  }
698
998
  writeLimitsOverride(true);
699
999
  print('');
700
1000
  print(`Wrote ${limitsOverridePath()}`);
701
1001
  try {
702
- await execFileAsync('systemctl', ['--user', 'daemon-reload']);
1002
+ await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
703
1003
  print('systemctl --user daemon-reload — done.');
704
1004
  print('Restart when sessions are idle: systemctl --user restart devbridge-runner');
705
1005
  }
@@ -725,10 +1025,19 @@ Usage:
725
1025
  devbridge-runner install-service install + start systemd user service
726
1026
  devbridge-runner status connection status (exit 0 = connected)
727
1027
  devbridge-runner set-token <dbr_token> store a rotated runner token
728
- devbridge-runner doctor [--fix] report (and repair) resource limits
1028
+ devbridge-runner doctor [--fix] [path...] will an agent work here? logins, settings,
1029
+ project permissions, resource limits
729
1030
  `;
730
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();
731
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
+ }
732
1041
  switch (command) {
733
1042
  case 'pair':
734
1043
  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');
@@ -961,16 +961,16 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
961
961
  sessionId: z.ZodOptional<z.ZodString>;
962
962
  args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
963
963
  }, "strip", z.ZodTypeAny, {
964
+ name: string;
964
965
  type: "command";
965
966
  requestId: string;
966
- name: string;
967
967
  sessionId?: string | undefined;
968
968
  workspaceId?: string | undefined;
969
969
  args?: Record<string, unknown> | undefined;
970
970
  }, {
971
+ name: string;
971
972
  type: "command";
972
973
  requestId: string;
973
- name: string;
974
974
  sessionId?: string | undefined;
975
975
  workspaceId?: string | undefined;
976
976
  args?: Record<string, unknown> | undefined;
@@ -62,6 +62,20 @@ export interface SelfUpdateOptions {
62
62
  * has one above `packages/runner`.
63
63
  */
64
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;
65
79
  /**
66
80
  * Is something going to restart us?
67
81
  *
@@ -1,5 +1,6 @@
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';
@@ -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');
@@ -15,12 +15,24 @@
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
34
  export declare function unitPath(home?: string): string;
23
- export declare function buildUnit(execStart?: string): string;
35
+ export declare function buildUnit(execStart?: string, nodeBinary?: string): string;
24
36
  /**
25
37
  * Resource policy for the service, and why it does not live in the unit above.
26
38
  *