@bridge4dev/runner 0.26.0 → 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 +29 -1
- package/dist/auth-relay.js +228 -13
- package/dist/environment.d.ts +15 -0
- package/dist/environment.js +25 -1
- package/dist/index.js +146 -10
- package/dist/protocol.d.ts +5 -5
- package/dist/recipe-schema.d.ts +1 -1
- package/dist/self-update.d.ts +43 -2
- package/dist/self-update.js +137 -43
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -10,7 +10,8 @@ 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 { installIsWritable, 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';
|
|
@@ -103,8 +104,13 @@ function hasExecutable(name) {
|
|
|
103
104
|
* older runner drops a frame it cannot parse without ever replying, so a
|
|
104
105
|
* command it does not know would just hang until the gateway timeout.
|
|
105
106
|
*/
|
|
106
|
-
function runnerCapabilities() {
|
|
107
|
+
function runnerCapabilities(apiUrlOverride) {
|
|
107
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;
|
|
108
114
|
const localLimit = config?.limits?.max_sessions;
|
|
109
115
|
// Session 14: the machine owner's veto. Default on — the safety of the
|
|
110
116
|
// feature is that a DevBridge manager approves every command first — but the
|
|
@@ -135,6 +141,37 @@ function runnerCapabilities() {
|
|
|
135
141
|
* is only half the instruction without a name to restart under.
|
|
136
142
|
*/
|
|
137
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
|
+
: {}),
|
|
138
175
|
/**
|
|
139
176
|
* A stricter ceiling set on the machine itself (layer 1). Reported so the
|
|
140
177
|
* dashboard can explain why raising the number there changed nothing.
|
|
@@ -306,7 +343,7 @@ async function cmdPair(args) {
|
|
|
306
343
|
name,
|
|
307
344
|
runnerVersion: RUNNER_VERSION,
|
|
308
345
|
osInfo: `${os.type()} ${os.release()} ${os.arch()}`.slice(0, 200),
|
|
309
|
-
capabilities: runnerCapabilities(),
|
|
346
|
+
capabilities: runnerCapabilities(apiUrl),
|
|
310
347
|
}),
|
|
311
348
|
});
|
|
312
349
|
const body = (await response.json().catch(() => null));
|
|
@@ -429,6 +466,12 @@ async function cmdDaemon() {
|
|
|
429
466
|
log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
|
|
430
467
|
sweepOrphanedMcpConfigs();
|
|
431
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
|
+
}
|
|
432
475
|
const agents = installedAgents();
|
|
433
476
|
const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
|
|
434
477
|
const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
|
|
@@ -654,8 +697,16 @@ async function runnerChecks() {
|
|
|
654
697
|
const state = await systemctlProperty('ActiveState');
|
|
655
698
|
const enabled = await systemctlProperty('UnitFileState');
|
|
656
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;
|
|
657
708
|
checks.push({
|
|
658
|
-
ok:
|
|
709
|
+
ok: durable,
|
|
659
710
|
name: 'service',
|
|
660
711
|
detail: running
|
|
661
712
|
? `running${enabled === 'enabled' ? ', starts on boot' : ' — but NOT enabled: it will not come back after a reboot'}` +
|
|
@@ -663,7 +714,7 @@ async function runnerChecks() {
|
|
|
663
714
|
? ', survives logout'
|
|
664
715
|
: linger === false
|
|
665
716
|
? ', but linger is OFF: it stops when this user logs out'
|
|
666
|
-
: '')
|
|
717
|
+
: ', linger state unknown')
|
|
667
718
|
: `NOT running (${state ?? 'unknown'})`,
|
|
668
719
|
...(running
|
|
669
720
|
? enabled !== 'enabled'
|
|
@@ -725,10 +776,27 @@ async function runnerChecks() {
|
|
|
725
776
|
...(canUpdate || packageDir === null
|
|
726
777
|
? {}
|
|
727
778
|
: {
|
|
728
|
-
|
|
729
|
-
|
|
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)`,
|
|
730
786
|
}),
|
|
731
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
|
+
});
|
|
732
800
|
return checks;
|
|
733
801
|
}
|
|
734
802
|
async function agentChecks() {
|
|
@@ -744,12 +812,45 @@ async function agentChecks() {
|
|
|
744
812
|
process.env['DEVBRIDGE_RUNNER_LOG'] = previousLogLevel;
|
|
745
813
|
});
|
|
746
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
|
+
});
|
|
747
849
|
for (const [agent, info] of [
|
|
748
850
|
['claude', auth.claude],
|
|
749
851
|
['codex', auth.codex],
|
|
750
852
|
]) {
|
|
751
853
|
const signedIn = info.status === 'ok';
|
|
752
|
-
const command = agent === 'claude' ? 'claude' : 'codex login';
|
|
753
854
|
checks.push({
|
|
754
855
|
ok: signedIn,
|
|
755
856
|
name: `${agent} login`,
|
|
@@ -758,12 +859,39 @@ async function agentChecks() {
|
|
|
758
859
|
...(signedIn
|
|
759
860
|
? {}
|
|
760
861
|
: {
|
|
761
|
-
|
|
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)`,
|
|
762
871
|
}),
|
|
763
872
|
});
|
|
764
873
|
}
|
|
765
874
|
const contour = agentConfigContour(me.home);
|
|
766
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
|
+
});
|
|
767
895
|
checks.push({
|
|
768
896
|
ok: contour.claudeDir,
|
|
769
897
|
name: 'claude config',
|
|
@@ -851,7 +979,15 @@ async function projectChecks(target, fix) {
|
|
|
851
979
|
name: 'docker',
|
|
852
980
|
detail: docker.problem ?? 'usable by this user',
|
|
853
981
|
...(docker.problem
|
|
854
|
-
? {
|
|
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
|
+
}
|
|
855
991
|
: {}),
|
|
856
992
|
});
|
|
857
993
|
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -179,11 +179,11 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
179
179
|
id: string;
|
|
180
180
|
title: string;
|
|
181
181
|
}[];
|
|
182
|
-
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
183
182
|
mcp?: {
|
|
184
183
|
url: string;
|
|
185
184
|
token: string;
|
|
186
185
|
} | undefined;
|
|
186
|
+
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
187
187
|
model?: string | null | undefined;
|
|
188
188
|
effort?: string | null | undefined;
|
|
189
189
|
epoch?: number | undefined;
|
|
@@ -381,11 +381,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
381
381
|
id: string;
|
|
382
382
|
title: string;
|
|
383
383
|
}[];
|
|
384
|
-
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
385
384
|
mcp?: {
|
|
386
385
|
url: string;
|
|
387
386
|
token: string;
|
|
388
387
|
} | undefined;
|
|
388
|
+
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
389
389
|
model?: string | null | undefined;
|
|
390
390
|
effort?: string | null | undefined;
|
|
391
391
|
epoch?: number | undefined;
|
|
@@ -469,11 +469,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
469
469
|
id: string;
|
|
470
470
|
title: string;
|
|
471
471
|
}[];
|
|
472
|
-
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
473
472
|
mcp?: {
|
|
474
473
|
url: string;
|
|
475
474
|
token: string;
|
|
476
475
|
} | undefined;
|
|
476
|
+
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
477
477
|
model?: string | null | undefined;
|
|
478
478
|
effort?: string | null | undefined;
|
|
479
479
|
epoch?: number | undefined;
|
|
@@ -695,11 +695,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
695
695
|
id: string;
|
|
696
696
|
title: string;
|
|
697
697
|
}[];
|
|
698
|
-
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
699
698
|
mcp?: {
|
|
700
699
|
url: string;
|
|
701
700
|
token: string;
|
|
702
701
|
} | undefined;
|
|
702
|
+
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
703
703
|
model?: string | null | undefined;
|
|
704
704
|
effort?: string | null | undefined;
|
|
705
705
|
epoch?: number | undefined;
|
|
@@ -778,11 +778,11 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
778
778
|
id: string;
|
|
779
779
|
title: string;
|
|
780
780
|
}[];
|
|
781
|
-
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
782
781
|
mcp?: {
|
|
783
782
|
url: string;
|
|
784
783
|
token: string;
|
|
785
784
|
} | undefined;
|
|
785
|
+
mode?: "ask" | "plan" | "auto" | "full" | undefined;
|
|
786
786
|
model?: string | null | undefined;
|
|
787
787
|
effort?: string | null | undefined;
|
|
788
788
|
epoch?: number | undefined;
|
package/dist/recipe-schema.d.ts
CHANGED
|
@@ -253,6 +253,7 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
|
|
|
253
253
|
shaPath?: string | undefined;
|
|
254
254
|
} | undefined;
|
|
255
255
|
}, {
|
|
256
|
+
version?: 1 | undefined;
|
|
256
257
|
preview?: {
|
|
257
258
|
run: string;
|
|
258
259
|
url?: string | undefined;
|
|
@@ -260,7 +261,6 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
|
|
|
260
261
|
timeoutSec?: number | undefined;
|
|
261
262
|
stop?: string | undefined;
|
|
262
263
|
} | undefined;
|
|
263
|
-
version?: 1 | undefined;
|
|
264
264
|
notes?: string | undefined;
|
|
265
265
|
steps?: {
|
|
266
266
|
verify?: {
|
package/dist/self-update.d.ts
CHANGED
|
@@ -62,6 +62,28 @@ export interface SelfUpdateOptions {
|
|
|
62
62
|
* has one above `packages/runner`.
|
|
63
63
|
*/
|
|
64
64
|
export declare function resolveInstalledPackageDir(entry?: string): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* The npm prefix the RUNNING package was installed into.
|
|
67
|
+
*
|
|
68
|
+
* This is the whole fix for the dedicated-user layout. `npm install -g` obeys
|
|
69
|
+
* npm's *configured* prefix, and `--prefix` is a per-invocation flag that npm
|
|
70
|
+
* persists nowhere — so the install the instructions prescribed for a dedicated
|
|
71
|
+
* user (`npm install -g --prefix ~/.local …`) produced a runner in
|
|
72
|
+
* `~/.local/lib/node_modules` while every later `npm install -g` aimed at
|
|
73
|
+
* `/usr`. Measured on a live server: `npm prefix -g` → `/usr`,
|
|
74
|
+
* `installIsWritable` → true (both checked directories really are the user's),
|
|
75
|
+
* and then npm dies with EACCES *inside* the update — the exact
|
|
76
|
+
* «the permissions to access this file as the current user» the owner pasted.
|
|
77
|
+
*
|
|
78
|
+
* Deriving the prefix from where we physically are is the truthful answer: it
|
|
79
|
+
* cannot disagree with reality, and it costs no subprocess. Returns null for a
|
|
80
|
+
* layout that is not a global npm install (a source checkout, a vendored copy),
|
|
81
|
+
* where the caller should fall back to `npm prefix -g`.
|
|
82
|
+
*
|
|
83
|
+
* `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>`
|
|
84
|
+
* `<prefix>/lib/node_modules/pkg` → `<prefix>`
|
|
85
|
+
*/
|
|
86
|
+
export declare function installPrefixFor(packageDir?: string | null): string | null;
|
|
65
87
|
/**
|
|
66
88
|
* Can this user actually replace the installed package?
|
|
67
89
|
*
|
|
@@ -72,8 +94,11 @@ export declare function resolveInstalledPackageDir(entry?: string): string | nul
|
|
|
72
94
|
* current user» — a dashboard button that cannot work, and an error in npm's
|
|
73
95
|
* words rather than ours.
|
|
74
96
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
97
|
+
* Three directories matter, because npm rewrites all three: the package itself,
|
|
98
|
+
* the `node_modules` above it, and `<prefix>/bin`, where the command symlink
|
|
99
|
+
* lives. `bin` was missing here and is not hypothetical — a prefix whose
|
|
100
|
+
* `lib/node_modules` was handed over with chown while `bin` stayed root-owned
|
|
101
|
+
* passes the first two and still fails.
|
|
77
102
|
*/
|
|
78
103
|
export declare function installIsWritable(packageDir?: string | null): boolean;
|
|
79
104
|
/**
|
|
@@ -92,5 +117,21 @@ export declare function isSupervisedProcess(env?: NodeJS.ProcessEnv): boolean;
|
|
|
92
117
|
* never be talked into fetching its own replacement over http.
|
|
93
118
|
*/
|
|
94
119
|
export declare function isTrustedTarballUrl(tarballUrl: string, apiUrl: string): boolean;
|
|
120
|
+
/**
|
|
121
|
+
* The command a person can paste on the server to update this exact machine.
|
|
122
|
+
*
|
|
123
|
+
* Built by the runner rather than by the dashboard because only the runner
|
|
124
|
+
* knows the three things that make it correct: which prefix it lives in, which
|
|
125
|
+
* user it runs as, and that user's uid for `XDG_RUNTIME_DIR`. The dashboard
|
|
126
|
+
* used to assemble a generic `npm install -g <url> && systemctl --user restart`
|
|
127
|
+
* and then patch it with string surgery — which produced a command that could
|
|
128
|
+
* not work on precisely the machines that needed it.
|
|
129
|
+
*/
|
|
130
|
+
export declare function manualUpdateCommand(tarballUrl: string, options?: {
|
|
131
|
+
prefix?: string | null;
|
|
132
|
+
user?: string;
|
|
133
|
+
uid?: number;
|
|
134
|
+
packageDir?: string | null;
|
|
135
|
+
}): string;
|
|
95
136
|
export declare function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdateOutcome>;
|
|
96
137
|
//# sourceMappingURL=self-update.d.ts.map
|
package/dist/self-update.js
CHANGED
|
@@ -55,6 +55,51 @@ export function resolveInstalledPackageDir(entry = process.argv[1] ?? '') {
|
|
|
55
55
|
}
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The npm prefix the RUNNING package was installed into.
|
|
60
|
+
*
|
|
61
|
+
* This is the whole fix for the dedicated-user layout. `npm install -g` obeys
|
|
62
|
+
* npm's *configured* prefix, and `--prefix` is a per-invocation flag that npm
|
|
63
|
+
* persists nowhere — so the install the instructions prescribed for a dedicated
|
|
64
|
+
* user (`npm install -g --prefix ~/.local …`) produced a runner in
|
|
65
|
+
* `~/.local/lib/node_modules` while every later `npm install -g` aimed at
|
|
66
|
+
* `/usr`. Measured on a live server: `npm prefix -g` → `/usr`,
|
|
67
|
+
* `installIsWritable` → true (both checked directories really are the user's),
|
|
68
|
+
* and then npm dies with EACCES *inside* the update — the exact
|
|
69
|
+
* «the permissions to access this file as the current user» the owner pasted.
|
|
70
|
+
*
|
|
71
|
+
* Deriving the prefix from where we physically are is the truthful answer: it
|
|
72
|
+
* cannot disagree with reality, and it costs no subprocess. Returns null for a
|
|
73
|
+
* layout that is not a global npm install (a source checkout, a vendored copy),
|
|
74
|
+
* where the caller should fall back to `npm prefix -g`.
|
|
75
|
+
*
|
|
76
|
+
* `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>`
|
|
77
|
+
* `<prefix>/lib/node_modules/pkg` → `<prefix>`
|
|
78
|
+
*/
|
|
79
|
+
export function installPrefixFor(packageDir = resolveInstalledPackageDir()) {
|
|
80
|
+
if (!packageDir)
|
|
81
|
+
return null;
|
|
82
|
+
const marker = `${path.sep}node_modules${path.sep}`;
|
|
83
|
+
const at = packageDir.lastIndexOf(marker);
|
|
84
|
+
if (at < 0)
|
|
85
|
+
return null;
|
|
86
|
+
const nodeModules = packageDir.slice(0, at + marker.length - 1);
|
|
87
|
+
const lib = path.dirname(nodeModules);
|
|
88
|
+
// Anything else is not the layout `npm install -g --prefix` produces, and
|
|
89
|
+
// guessing a prefix that npm would not use is worse than not guessing.
|
|
90
|
+
if (path.basename(lib) !== 'lib')
|
|
91
|
+
return null;
|
|
92
|
+
return path.dirname(lib);
|
|
93
|
+
}
|
|
94
|
+
function isWritable(target) {
|
|
95
|
+
try {
|
|
96
|
+
fs.accessSync(target, fs.constants.W_OK);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
58
103
|
/**
|
|
59
104
|
* Can this user actually replace the installed package?
|
|
60
105
|
*
|
|
@@ -65,24 +110,29 @@ export function resolveInstalledPackageDir(entry = process.argv[1] ?? '') {
|
|
|
65
110
|
* current user» — a dashboard button that cannot work, and an error in npm's
|
|
66
111
|
* words rather than ours.
|
|
67
112
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
113
|
+
* Three directories matter, because npm rewrites all three: the package itself,
|
|
114
|
+
* the `node_modules` above it, and `<prefix>/bin`, where the command symlink
|
|
115
|
+
* lives. `bin` was missing here and is not hypothetical — a prefix whose
|
|
116
|
+
* `lib/node_modules` was handed over with chown while `bin` stayed root-owned
|
|
117
|
+
* passes the first two and still fails.
|
|
70
118
|
*/
|
|
71
119
|
export function installIsWritable(packageDir = resolveInstalledPackageDir()) {
|
|
72
120
|
if (!packageDir)
|
|
73
121
|
return false;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
fs.accessSync(target, fs.constants.W_OK);
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
};
|
|
122
|
+
const marker = `${path.sep}node_modules${path.sep}`;
|
|
123
|
+
const at = packageDir.lastIndexOf(marker);
|
|
83
124
|
// `<prefix>/lib/node_modules/@scope/pkg` → `<prefix>/lib/node_modules`
|
|
84
|
-
const nodeModules =
|
|
85
|
-
|
|
125
|
+
const nodeModules = at >= 0 ? packageDir.slice(0, at + marker.length - 1) : path.dirname(packageDir);
|
|
126
|
+
if (!isWritable(packageDir) || !isWritable(nodeModules))
|
|
127
|
+
return false;
|
|
128
|
+
const prefix = installPrefixFor(packageDir);
|
|
129
|
+
// No `bin` yet is fine — npm creates it. An existing one we cannot write is not.
|
|
130
|
+
if (prefix) {
|
|
131
|
+
const bin = path.join(prefix, 'bin');
|
|
132
|
+
if (fs.existsSync(bin) && !isWritable(bin))
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
86
136
|
}
|
|
87
137
|
/**
|
|
88
138
|
* Is something going to restart us?
|
|
@@ -140,17 +190,23 @@ const COMMAND_NAME = 'devbridge-runner';
|
|
|
140
190
|
* command — the stable thing, referenced by a unit file we do not control —
|
|
141
191
|
* points at the new one. So we follow the command.
|
|
142
192
|
*/
|
|
143
|
-
async function commandOwner(exec) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
193
|
+
async function commandOwner(exec, installPrefix = null) {
|
|
194
|
+
// Where we actually live outranks what npm is configured to think. Under the
|
|
195
|
+
// dedicated-user layout `npm prefix -g` answers `/usr` — a directory that on
|
|
196
|
+
// such a host holds no runner at all — so asking npm first returned null and
|
|
197
|
+
// took the EEXIST rename-recovery and the unit repair down with it.
|
|
198
|
+
let prefix = installPrefix;
|
|
199
|
+
if (!prefix) {
|
|
200
|
+
try {
|
|
201
|
+
const result = await exec('npm', ['prefix', '-g'], {
|
|
202
|
+
timeout: VERIFY_TIMEOUT_MS,
|
|
203
|
+
env: npmEnv(),
|
|
204
|
+
});
|
|
205
|
+
prefix = result.stdout.trim();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
154
210
|
}
|
|
155
211
|
if (!prefix)
|
|
156
212
|
return null;
|
|
@@ -167,13 +223,55 @@ async function commandOwner(exec) {
|
|
|
167
223
|
return null;
|
|
168
224
|
}
|
|
169
225
|
}
|
|
170
|
-
function installArgs(source) {
|
|
226
|
+
function installArgs(source, prefix) {
|
|
171
227
|
// `--ignore-scripts` matches the documented install: this package and its whole
|
|
172
228
|
// tree have no install/postinstall scripts, so nothing legitimate is skipped —
|
|
173
229
|
// and an update pulled over the network gets no chance to run anything at
|
|
174
230
|
// install time. `--loglevel=error` because npm's ERESOLVE warning about zod is
|
|
175
231
|
// expected, harmless and long enough to bury the line that matters.
|
|
176
|
-
|
|
232
|
+
//
|
|
233
|
+
// `--prefix` names the prefix the runner is ALREADY installed in. Without it
|
|
234
|
+
// npm uses its configured global prefix, which on a dedicated-user install is
|
|
235
|
+
// a directory the daemon cannot write — and, on the rarer host where it can,
|
|
236
|
+
// npm cheerfully installs a SECOND copy somewhere the service does not exec,
|
|
237
|
+
// reports success, and the runner restarts on the old version forever.
|
|
238
|
+
return [
|
|
239
|
+
'install',
|
|
240
|
+
'-g',
|
|
241
|
+
'--ignore-scripts',
|
|
242
|
+
'--loglevel=error',
|
|
243
|
+
...(prefix ? ['--prefix', prefix] : []),
|
|
244
|
+
source,
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The command a person can paste on the server to update this exact machine.
|
|
249
|
+
*
|
|
250
|
+
* Built by the runner rather than by the dashboard because only the runner
|
|
251
|
+
* knows the three things that make it correct: which prefix it lives in, which
|
|
252
|
+
* user it runs as, and that user's uid for `XDG_RUNTIME_DIR`. The dashboard
|
|
253
|
+
* used to assemble a generic `npm install -g <url> && systemctl --user restart`
|
|
254
|
+
* and then patch it with string surgery — which produced a command that could
|
|
255
|
+
* not work on precisely the machines that needed it.
|
|
256
|
+
*/
|
|
257
|
+
export function manualUpdateCommand(tarballUrl, options = {}) {
|
|
258
|
+
const packageDir = options.packageDir === undefined ? resolveInstalledPackageDir() : options.packageDir;
|
|
259
|
+
const prefix = options.prefix === undefined ? installPrefixFor(packageDir) : options.prefix;
|
|
260
|
+
const user = options.user ?? os.userInfo().username;
|
|
261
|
+
const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : -1);
|
|
262
|
+
const install = ['npm install -g --ignore-scripts --loglevel=error']
|
|
263
|
+
.concat(prefix ? [`--prefix ${prefix}`] : [])
|
|
264
|
+
.concat([tarballUrl])
|
|
265
|
+
.join(' ');
|
|
266
|
+
const restart = 'systemctl --user restart devbridge-runner';
|
|
267
|
+
// Writable by us → one line, run as ourselves. Not writable → the install
|
|
268
|
+
// half needs root and the restart half needs the daemon's own user, and
|
|
269
|
+
// «run it as root» alone is how people ended up with a new package and an
|
|
270
|
+
// old process still running.
|
|
271
|
+
if (installIsWritable(packageDir))
|
|
272
|
+
return `${install} && ${restart}`;
|
|
273
|
+
const runtime = uid >= 0 ? `/run/user/${uid}` : `/run/user/$(id -u ${user})`;
|
|
274
|
+
return `sudo ${install} && sudo -iu ${user} env XDG_RUNTIME_DIR=${runtime} ${restart}`;
|
|
177
275
|
}
|
|
178
276
|
/**
|
|
179
277
|
* Install a global package, clearing the way if a DIFFERENTLY-NAMED build of this
|
|
@@ -186,25 +284,22 @@ function installArgs(source) {
|
|
|
186
284
|
* one owner instead of two, which is also the only state the NEXT update can
|
|
187
285
|
* work from.
|
|
188
286
|
*/
|
|
189
|
-
async function installGlobal(exec, source) {
|
|
287
|
+
async function installGlobal(exec, source, prefix) {
|
|
190
288
|
try {
|
|
191
|
-
await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
|
|
289
|
+
await exec('npm', installArgs(source, prefix), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
|
|
192
290
|
return;
|
|
193
291
|
}
|
|
194
292
|
catch (error) {
|
|
195
293
|
if (!/EEXIST/i.test(describe(error)))
|
|
196
294
|
throw error;
|
|
197
|
-
const owner = await commandOwner(exec);
|
|
295
|
+
const owner = await commandOwner(exec, prefix);
|
|
198
296
|
if (!owner)
|
|
199
297
|
throw error;
|
|
200
298
|
log.warn('self-update: the command belongs to another package — retiring it', {
|
|
201
299
|
package: owner.name,
|
|
202
300
|
});
|
|
203
|
-
await exec('npm', ['uninstall', '-g', '--loglevel=error', owner.name], {
|
|
204
|
-
|
|
205
|
-
env: npmEnv(),
|
|
206
|
-
});
|
|
207
|
-
await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
|
|
301
|
+
await exec('npm', ['uninstall', '-g', '--loglevel=error', ...(prefix ? ['--prefix', prefix] : []), owner.name], { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
|
|
302
|
+
await exec('npm', installArgs(source, prefix), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
|
|
208
303
|
}
|
|
209
304
|
}
|
|
210
305
|
/**
|
|
@@ -256,13 +351,12 @@ export async function selfUpdate(options) {
|
|
|
256
351
|
// runner's own user.
|
|
257
352
|
if (!installIsWritable(packageDir)) {
|
|
258
353
|
const user = os.userInfo().username;
|
|
259
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : -1;
|
|
260
354
|
return fail(`The runner package in ${packageDir} belongs to another user, and this daemon runs as ${user}, ` +
|
|
261
|
-
'so it cannot replace itself.
|
|
262
|
-
|
|
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`);
|
|
355
|
+
'so it cannot replace itself. Run this on the server instead:\n ' +
|
|
356
|
+
manualUpdateCommand(options.tarballUrl, { packageDir }));
|
|
265
357
|
}
|
|
358
|
+
// Derived once and threaded through every npm call below.
|
|
359
|
+
const prefix = installPrefixFor(packageDir);
|
|
266
360
|
// Pack the current version FIRST: without a rollback artefact there is no
|
|
267
361
|
// honest way back if the new build turns out to be broken.
|
|
268
362
|
const rollbackDir = path.join(stateDir(), 'rollback');
|
|
@@ -287,7 +381,7 @@ export async function selfUpdate(options) {
|
|
|
287
381
|
return fail('Could not prepare a rollback copy of the current version — update aborted');
|
|
288
382
|
}
|
|
289
383
|
try {
|
|
290
|
-
await installGlobal(exec, options.tarballUrl);
|
|
384
|
+
await installGlobal(exec, options.tarballUrl, prefix);
|
|
291
385
|
}
|
|
292
386
|
catch (error) {
|
|
293
387
|
return fail(`Install failed: ${describe(error)}`, { rollbackTarball });
|
|
@@ -295,7 +389,7 @@ export async function selfUpdate(options) {
|
|
|
295
389
|
// Where the new build actually landed. After a rename `packageDir` is the
|
|
296
390
|
// directory we just retired, so its manifest would report the OLD version —
|
|
297
391
|
// and the smoke test below would run code that no longer exists.
|
|
298
|
-
const installed = await commandOwner(exec);
|
|
392
|
+
const installed = await commandOwner(exec, prefix);
|
|
299
393
|
const newPackageDir = installed?.dir ?? packageDir;
|
|
300
394
|
const toVersion = readVersion(newPackageDir) ?? undefined;
|
|
301
395
|
// The real test: does the newly installed build start? `--version` loads the
|
|
@@ -318,12 +412,12 @@ export async function selfUpdate(options) {
|
|
|
318
412
|
// Through the same door as the install above: if the failed update renamed
|
|
319
413
|
// the package, the command now belongs to the new name and putting the old
|
|
320
414
|
// one back hits the very same EEXIST.
|
|
321
|
-
await installGlobal(exec, rollbackTarball);
|
|
415
|
+
await installGlobal(exec, rollbackTarball, prefix);
|
|
322
416
|
return fail(`The new version did not start (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
323
417
|
}
|
|
324
418
|
catch (rollbackError) {
|
|
325
419
|
return fail(`The new version did not start (${detail}) and the rollback failed too (${describe(rollbackError)}). ` +
|
|
326
|
-
`Restore it on the server with: npm install -g ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
420
|
+
`Restore it on the server with: npm install -g${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
327
421
|
}
|
|
328
422
|
}
|
|
329
423
|
// The service unit may be pinned to a file inside the directory this update
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.27.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|