@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/agent-auth.d.ts +29 -0
- package/dist/agent-auth.js +136 -0
- package/dist/auth-relay.d.ts +62 -4
- package/dist/auth-relay.js +423 -25
- package/dist/environment.d.ts +186 -0
- package/dist/environment.js +433 -0
- package/dist/git.d.ts +10 -0
- package/dist/git.js +94 -5
- package/dist/index.js +466 -21
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +11 -0
- package/dist/protocol.d.ts +7 -7
- package/dist/recipe-schema.d.ts +1 -1
- package/dist/self-update.d.ts +55 -0
- package/dist/self-update.js +164 -25
- 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,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
|
-
|
|
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
|
-
|
|
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'], {
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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]
|
|
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');
|