@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/auth-relay.d.ts +33 -3
- package/dist/auth-relay.js +199 -16
- package/dist/environment.d.ts +171 -0
- package/dist/environment.js +409 -0
- package/dist/git.d.ts +10 -0
- package/dist/git.js +94 -5
- package/dist/index.js +328 -19
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +11 -0
- package/dist/protocol.d.ts +2 -2
- package/dist/self-update.d.ts +14 -0
- package/dist/self-update.js +45 -0
- package/dist/service-unit.d.ts +13 -1
- package/dist/service-unit.js +41 -10
- package/dist/supervisor.js +35 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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'], {
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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]
|
|
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');
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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;
|
package/dist/self-update.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/self-update.js
CHANGED
|
@@ -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');
|
package/dist/service-unit.d.ts
CHANGED
|
@@ -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
|
*
|