@bridge4dev/runner 0.46.1 → 0.48.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.
@@ -0,0 +1,475 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { AGENT_VERSION_PATTERN, agentByWireKey, compareAgentVersions, } from './agent-registry.js';
6
+ import { measureAgent } from './agent-versions.js';
7
+ import { ensureAgentPath, whichExecutable } from './environment.js';
8
+ import { log } from './log.js';
9
+ import { stateDir } from './paths.js';
10
+ import { describeCommandFailure, installIsWritable, installPrefixFor, resolveInstalledPackageDir, } from './self-update.js';
11
+ /**
12
+ * Run a child and wait for it, with stdin closed.
13
+ *
14
+ * The EOF is not decoration: `agent-versions.ts` had to learn it because
15
+ * `claude doctor` reads stdin and sits there until the timeout otherwise
16
+ * (measured, not assumed). This path spawns the same vendor CLI twice —
17
+ * `bash install.sh` execs `claude install <version>`, and the rollback runs it
18
+ * directly — and a block there would cost the full install timeout with the
19
+ * install lock held.
20
+ */
21
+ const runChild = (file, args, options) => new Promise((resolve, reject) => {
22
+ const child = execFile(file, args, { timeout: options.timeout, env: options.env, maxBuffer: 4_000_000 }, (error, stdout, stderr) => {
23
+ if (error)
24
+ reject(error);
25
+ else
26
+ resolve({ stdout, stderr });
27
+ });
28
+ child.stdin?.end();
29
+ });
30
+ /**
31
+ * Generous on purpose. The API stops waiting after 240s and reports «installing,
32
+ * waiting for the report», with the `agent_versions` frame as the late source of
33
+ * truth — so the runner giving up early would turn a slow 300 MB download into a
34
+ * failure that never happened.
35
+ */
36
+ const INSTALL_TIMEOUT_MS = 600_000;
37
+ /**
38
+ * Both agents ship 200–330 MB, and the native installer needs room for the new
39
+ * file beside the old one. Below this we refuse with a number rather than let
40
+ * the machine fill up — `self-update.ts` has no such check, and this is the
41
+ * first install path in the runner that downloads that much.
42
+ */
43
+ const MIN_FREE_BYTES = 1024 * 1024 * 1024;
44
+ /** A shell script is a few kilobytes; a minute is a slow network, not a hang. */
45
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
46
+ /**
47
+ * Anthropic's native installer keeps every version it has ever downloaded as a
48
+ * separate file here and switches a launcher between them; `claude install
49
+ * <version>` is how you move the launcher, and that is what makes a rollback
50
+ * possible at all for a `script`-kind agent.
51
+ *
52
+ * Deliberately NOT a registry field: the registry is mirrored character for
53
+ * character into `@devbridge/shared`, and today `script` has exactly one member.
54
+ * When a second one arrives (Antigravity ships a script installer too) this
55
+ * becomes a per-agent field and the mirror grows with it.
56
+ */
57
+ export const NATIVE_VERSIONS_DIR = ['.local', 'share', 'claude', 'versions'];
58
+ /**
59
+ * How the network is reached, on a machine that does not reach it directly.
60
+ *
61
+ * npm reads a proxy out of its own config as well; `curl` inside a vendor's
62
+ * `install.sh` reads ONLY the environment. Stripping these was the difference
63
+ * between «Update runner» working behind an outbound proxy and «Install Claude»
64
+ * failing there with a bare network error nobody could act on (QA of stage B,
65
+ * m8).
66
+ *
67
+ * Passed through rather than invented, and only these: they say WHERE to go, not
68
+ * who we are. No credential of ours is in this list, and the payload is still
69
+ * the registry's — the URL and the package name come from the build.
70
+ */
71
+ const NETWORK_ENV_KEYS = [
72
+ 'HTTP_PROXY',
73
+ 'HTTPS_PROXY',
74
+ 'http_proxy',
75
+ 'https_proxy',
76
+ 'NO_PROXY',
77
+ 'no_proxy',
78
+ 'ALL_PROXY',
79
+ 'all_proxy',
80
+ 'NODE_EXTRA_CA_CERTS',
81
+ 'SSL_CERT_FILE',
82
+ 'SSL_CERT_DIR',
83
+ ];
84
+ /**
85
+ * npm and the vendor scripts need PATH, HOME and a writable cache; everything
86
+ * else is stripped so provider credentials never reach a child that touches the
87
+ * network, and a stray `npm_config_prefix` cannot redirect the install.
88
+ */
89
+ function installEnv(homeDir) {
90
+ const env = {
91
+ PATH: process.env['PATH'] ?? '/usr/local/bin:/usr/bin:/bin',
92
+ HOME: homeDir,
93
+ npm_config_progress: 'false',
94
+ npm_config_fund: 'false',
95
+ npm_config_audit: 'false',
96
+ };
97
+ if (process.env['XDG_CACHE_HOME'])
98
+ env['XDG_CACHE_HOME'] = process.env['XDG_CACHE_HOME'];
99
+ for (const key of NETWORK_ENV_KEYS) {
100
+ const value = process.env[key];
101
+ if (value)
102
+ env[key] = value;
103
+ }
104
+ return env;
105
+ }
106
+ /**
107
+ * `--include=optional` is not a copy-paste slip next to C5, which takes the flag
108
+ * OFF the runner's own install. The two say the same thing about different
109
+ * packages: the runner no longer wants Claude's optional platform binary, while
110
+ * `@openai/codex` keeps its 320 MB executable in exactly such an optional
111
+ * package (`@openai/codex-linux-x64`). Dropping it here would install a shim
112
+ * that cannot run, and npm would still exit 0 — гоча #297, second helping.
113
+ *
114
+ * `--ignore-scripts` is safe and checked: neither agent package declares any
115
+ * lifecycle script.
116
+ */
117
+ function npmInstallArgs(packageName, version, prefix) {
118
+ return [
119
+ 'install',
120
+ '-g',
121
+ '--ignore-scripts',
122
+ '--include=optional',
123
+ '--loglevel=error',
124
+ ...(prefix ? ['--prefix', prefix] : []),
125
+ `${packageName}@${version}`,
126
+ ];
127
+ }
128
+ /**
129
+ * The command a human runs when we will not: an agent someone else's package
130
+ * manager owns, or a machine this daemon cannot write to.
131
+ */
132
+ export function manualAgentInstallCommand(runtime, version) {
133
+ return runtime.install.kind === 'npm-global'
134
+ ? `npm install -g --include=optional ${runtime.install.package}@${version}`
135
+ : `curl -fsSL ${runtime.install.url} | bash -s ${version}`;
136
+ }
137
+ /** Free bytes on the filesystem `target` lives on; null when we cannot tell. */
138
+ function freeBytesFor(target) {
139
+ try {
140
+ const stat = fs.statfsSync(target);
141
+ return Number(stat.bavail) * Number(stat.bsize);
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ /**
148
+ * The first existing directory of the chain — statfs needs a path that is
149
+ * actually there, and the place we are about to write into may not be yet.
150
+ */
151
+ function existingAncestor(candidates) {
152
+ for (const candidate of candidates) {
153
+ if (fs.existsSync(candidate))
154
+ return candidate;
155
+ }
156
+ return null;
157
+ }
158
+ /** Where this kind of install writes, for the space and ownership checks. */
159
+ function installTarget(runtime, homeDir, prefix) {
160
+ if (runtime.install.kind === 'npm-global') {
161
+ const lib = prefix ? path.join(prefix, 'lib', 'node_modules') : null;
162
+ return {
163
+ probe: existingAncestor([...(lib ? [lib] : []), ...(prefix ? [prefix] : []), homeDir]),
164
+ describe: prefix ?? 'the npm prefix',
165
+ };
166
+ }
167
+ const share = path.join(homeDir, '.local', 'share');
168
+ return {
169
+ probe: existingAncestor([share, path.join(homeDir, '.local'), homeDir]),
170
+ describe: homeDir,
171
+ };
172
+ }
173
+ /**
174
+ * Can this daemon write where this agent has to go?
175
+ *
176
+ * Checked per kind because the two kinds land in different places: a global npm
177
+ * package goes into the runner's own npm prefix (so the runner's own writability
178
+ * check is exactly the right question), while the native installer writes only
179
+ * into the daemon user's home.
180
+ */
181
+ function targetIsWritable(runtime, homeDir, packageDir) {
182
+ if (runtime.install.kind === 'npm-global')
183
+ return installIsWritable(packageDir);
184
+ // The native installer creates these if they are missing, so an ABSENT
185
+ // directory is not a refusal — but an existing one owned by somebody else is,
186
+ // and that is the real shape: Claude first installed as root, the daemon
187
+ // later moved to a dedicated user. Checking only `$HOME` passed that machine
188
+ // and let it fail with an EACCES from inside the vendor's script, which is
189
+ // precisely the error this section exists to replace with our own.
190
+ for (const target of [
191
+ homeDir,
192
+ path.join(homeDir, '.local', 'bin'),
193
+ path.join(homeDir, ...NATIVE_VERSIONS_DIR.slice(0, -1)),
194
+ ]) {
195
+ if (target !== homeDir && !fs.existsSync(target))
196
+ continue;
197
+ try {
198
+ fs.accessSync(target, fs.constants.W_OK);
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ }
204
+ return true;
205
+ }
206
+ /** Versions the native installer has on disk, newest first. */
207
+ export function nativeVersionsOnDisk(homeDir) {
208
+ const dir = path.join(homeDir, ...NATIVE_VERSIONS_DIR);
209
+ try {
210
+ return fs
211
+ .readdirSync(dir)
212
+ .filter((name) => new RegExp(AGENT_VERSION_PATTERN).test(name))
213
+ .sort((a, b) => compareAgentVersions(b, a) ?? 0);
214
+ }
215
+ catch {
216
+ return [];
217
+ }
218
+ }
219
+ /**
220
+ * Fetch the vendor's installer script to a file.
221
+ *
222
+ * `curl` rather than Node's `fetch`, and behind an outbound proxy that is the
223
+ * whole difference between working and not: undici reads no proxy from the
224
+ * environment, so a machine that reaches the internet only through
225
+ * `HTTPS_PROXY` failed here — while the script it was fetching would itself
226
+ * have reached the network perfectly well, because `curl` inside it does read
227
+ * the environment. Fixing one half and not the other would have been an
228
+ * improvement nobody could use.
229
+ *
230
+ * `curl` is not an extra dependency: the vendor's script runs it on the next
231
+ * line, so a machine without it cannot install this agent either way.
232
+ *
233
+ * Downloaded to a file and never piped into a shell, exactly as before — a
234
+ * truncated transfer then fails to parse instead of running half a script.
235
+ * `--fail` turns an HTTP error into a non-zero exit rather than an error page
236
+ * saved as an installer; `--proto =https` refuses a redirect that leaves TLS.
237
+ */
238
+ async function downloadToFile(url, destination, exec, env) {
239
+ await exec('curl', [
240
+ '--fail',
241
+ '--silent',
242
+ '--show-error',
243
+ '--location',
244
+ '--proto',
245
+ '=https',
246
+ '--output',
247
+ destination,
248
+ url,
249
+ ], { timeout: DOWNLOAD_TIMEOUT_MS, env });
250
+ const written = fs.statSync(destination);
251
+ if (written.size === 0)
252
+ throw new Error('the installer script came back empty');
253
+ fs.chmodSync(destination, 0o700);
254
+ }
255
+ /**
256
+ * Install or update one agent CLI.
257
+ *
258
+ * Never throws: every failure comes back as an outcome, because the caller is a
259
+ * command handler whose reply is the only thing the dashboard will ever see.
260
+ */
261
+ export async function installAgent(options) {
262
+ const exec = options.exec ?? runChild;
263
+ const measure = options.measure ?? ((runtime) => measureAgent(runtime));
264
+ const homeDir = options.homeDir ?? process.env['HOME'] ?? os.homedir();
265
+ const free = options.freeBytes ?? freeBytesFor;
266
+ const download = options.download ??
267
+ ((url, destination) => downloadToFile(url, destination, exec, installEnv(homeDir)));
268
+ const refreshPath = options.refreshPath ?? ensureAgentPath;
269
+ const fail = (detail, extra = {}) => ({
270
+ ok: false,
271
+ agent: options.agent,
272
+ fromVersion: null,
273
+ detail,
274
+ ...extra,
275
+ });
276
+ // ── Refusals, all of them before anything is touched ──────────────────────
277
+ const runtime = agentByWireKey(options.agent);
278
+ if (!runtime)
279
+ return fail(`\`${options.agent}\` is not an agent this runner knows`);
280
+ if (!new RegExp(AGENT_VERSION_PATTERN).test(options.version)) {
281
+ return fail(`\`${options.version}\` is not a version number`);
282
+ }
283
+ const packageDir = options.packageDir === undefined ? resolveInstalledPackageDir() : options.packageDir;
284
+ const prefix = installPrefixFor(packageDir);
285
+ const before = await measure(runtime);
286
+ const fromVersion = before.version;
287
+ // A binary that is there but would not answer. Installing over it would throw
288
+ // away a version we cannot name, and «roll back to null» is not a rollback.
289
+ if (before.probeFailed) {
290
+ return fail(`${runtime.label} is installed on this server but does not report its version, ` +
291
+ 'so there would be no way back if the new one turned out to be broken. ' +
292
+ `Check it on the server with \`${runtime.bin} ${runtime.versionProbe.argv.join(' ')}\`.`, { fromVersion });
293
+ }
294
+ // Someone else's package manager owns this copy. Replacing it would leave two
295
+ // installations racing for the same command name.
296
+ if (fromVersion !== null && before.managedBy !== runtime.install.managedBy) {
297
+ return fail(`${runtime.label} on this server was installed with ${before.managedBy}, ` +
298
+ 'and this runner only manages its own installs. Update it on the server with:\n ' +
299
+ manualAgentInstallCommand(runtime, options.version), { fromVersion });
300
+ }
301
+ if (fromVersion === options.version) {
302
+ return {
303
+ ok: true,
304
+ agent: runtime.wireKey,
305
+ fromVersion,
306
+ toVersion: options.version,
307
+ detail: `${runtime.label} is already ${options.version}`,
308
+ };
309
+ }
310
+ if (fromVersion !== null &&
311
+ !options.allowDowngrade &&
312
+ (compareAgentVersions(options.version, fromVersion) ?? 0) < 0) {
313
+ return fail(`${options.version} is older than the ${fromVersion} already on this server`, {
314
+ fromVersion,
315
+ });
316
+ }
317
+ if (!targetIsWritable(runtime, homeDir, packageDir)) {
318
+ return fail(`${runtime.label} would be installed into ${installTarget(runtime, homeDir, prefix).describe}, ` +
319
+ `which does not belong to ${os.userInfo().username} — the user this runner runs as. ` +
320
+ 'Run this on the server instead:\n ' +
321
+ manualAgentInstallCommand(runtime, options.version), { fromVersion });
322
+ }
323
+ const target = installTarget(runtime, homeDir, prefix);
324
+ const freeBytes = target.probe ? free(target.probe) : null;
325
+ // null means «this platform cannot tell», which is a reason to go ahead rather
326
+ // than to refuse every install — the same call the verify runner makes.
327
+ if (freeBytes !== null && freeBytes < MIN_FREE_BYTES) {
328
+ return fail(`Only ${Math.round(freeBytes / 1024 / 1024)} MB free where ${runtime.label} would go — ` +
329
+ `it needs at least ${Math.round(MIN_FREE_BYTES / 1024 / 1024)} MB. Free some space and try again.`, { fromVersion });
330
+ }
331
+ // ── The install itself ────────────────────────────────────────────────────
332
+ // What a rollback would restore. For `npm-global` this is the version number,
333
+ // not a packed tarball: the plan wrote `npm pack`, copying the runner's own
334
+ // update, but the runner packs itself because the registry has no copy of the
335
+ // build it is replacing. An agent version DID come from the registry, and
336
+ // packing its installed directory would produce an 8 KB shim without the
337
+ // platform binary anyway — so the number is both the honest artefact and the
338
+ // sufficient one. The safety property the plan asked for is kept exactly:
339
+ // nothing is installed over a version we could not name (see `probeFailed`).
340
+ const rollbackVersion = fromVersion;
341
+ try {
342
+ if (runtime.install.kind === 'npm-global') {
343
+ await exec('npm', npmInstallArgs(runtime.install.package, options.version, prefix), {
344
+ timeout: INSTALL_TIMEOUT_MS,
345
+ env: installEnv(homeDir),
346
+ });
347
+ }
348
+ else {
349
+ const scriptDir = path.join(stateDir(), 'agent-install');
350
+ fs.mkdirSync(scriptDir, { recursive: true, mode: 0o700 });
351
+ const scriptPath = path.join(scriptDir, `${runtime.wireKey}-install.sh`);
352
+ // Downloaded to a file and executed, never piped into a shell: a truncated
353
+ // transfer then fails to parse instead of running half a script.
354
+ await download(runtime.install.url, scriptPath);
355
+ try {
356
+ await exec('bash', [scriptPath, options.version], {
357
+ timeout: INSTALL_TIMEOUT_MS,
358
+ env: installEnv(homeDir),
359
+ });
360
+ }
361
+ finally {
362
+ // In a `finally`, or every failed install leaves a vendor script behind
363
+ // in the runner's state directory.
364
+ fs.rmSync(scriptPath, { force: true });
365
+ }
366
+ }
367
+ }
368
+ catch (error) {
369
+ const detail = describeCommandFailure(error);
370
+ log.warn('agent-install: install failed', { agent: runtime.wireKey, error: detail });
371
+ return fail(`Installing ${runtime.label} ${options.version} failed: ${detail}`, {
372
+ fromVersion,
373
+ });
374
+ }
375
+ // ── The smoke test: ask the binary, not npm ───────────────────────────────
376
+ // Look again for directories the install has just created. `ensureAgentPath`
377
+ // runs once at daemon start and adds `~/.local/bin` only if it EXISTS then —
378
+ // and on a machine getting Claude for the first time it does not. Without
379
+ // this the smoke test below would search a PATH that cannot contain the file
380
+ // we just wrote, call a good install a failure, and roll it back. That is the
381
+ // headline case of this whole feature (Р11), not a corner of it.
382
+ refreshPath();
383
+ const after = await measure(runtime);
384
+ const landed = after.version === options.version && !after.probeFailed;
385
+ if (landed) {
386
+ log.info('agent-install: installed', {
387
+ agent: runtime.wireKey,
388
+ from: fromVersion,
389
+ to: options.version,
390
+ });
391
+ return {
392
+ ok: true,
393
+ agent: runtime.wireKey,
394
+ fromVersion,
395
+ toVersion: options.version,
396
+ };
397
+ }
398
+ const wrong = after.probeFailed
399
+ ? `${runtime.label} does not run after the install`
400
+ : after.version === null
401
+ ? `${runtime.label} is not on this server after the install`
402
+ : `${runtime.label} reports ${after.version} after installing ${options.version}`;
403
+ log.error('agent-install: the new version did not come up', {
404
+ agent: runtime.wireKey,
405
+ wanted: options.version,
406
+ got: after.version,
407
+ });
408
+ const rolledBack = await rollBack({
409
+ runtime,
410
+ version: rollbackVersion,
411
+ exec,
412
+ prefix,
413
+ homeDir,
414
+ measure,
415
+ });
416
+ return fail(rollbackVersion === null
417
+ ? `${wrong}. Nothing was replaced — the server is as it was.`
418
+ : rolledBack
419
+ ? `${wrong}. The previous version (${rollbackVersion}) was put back.`
420
+ : `${wrong}, and putting ${rollbackVersion} back failed too. Fix it on the server with:\n ` +
421
+ manualAgentInstallCommand(runtime, rollbackVersion), { fromVersion, ...(rollbackVersion !== null ? { rolledBack } : {}) });
422
+ }
423
+ /**
424
+ * Put the previous version back, by the route its own installer understands.
425
+ *
426
+ * Returns whether the machine really ended up on that version — asked of the
427
+ * binary again, because a rollback that silently failed is the one state worse
428
+ * than the broken install it was meant to undo.
429
+ */
430
+ async function rollBack(input) {
431
+ const { runtime, version, exec, prefix, homeDir, measure } = input;
432
+ if (version === null)
433
+ return false;
434
+ try {
435
+ if (runtime.install.kind === 'npm-global') {
436
+ await exec('npm', npmInstallArgs(runtime.install.package, version, prefix), {
437
+ timeout: INSTALL_TIMEOUT_MS,
438
+ env: installEnv(homeDir),
439
+ });
440
+ }
441
+ else {
442
+ // The native installer keeps the file; `claude install <version>` only has
443
+ // to point the launcher back at it. Prefer the version file itself over
444
+ // the launcher, which is exactly what the failed install may have broken.
445
+ const onDisk = nativeVersionsOnDisk(homeDir).includes(version)
446
+ ? path.join(homeDir, ...NATIVE_VERSIONS_DIR, version)
447
+ : whichExecutable(runtime.bin);
448
+ if (!onDisk)
449
+ return false;
450
+ await exec(onDisk, ['install', version], {
451
+ timeout: INSTALL_TIMEOUT_MS,
452
+ env: installEnv(homeDir),
453
+ });
454
+ }
455
+ }
456
+ catch (error) {
457
+ log.error('agent-install: rollback failed', {
458
+ agent: runtime.wireKey,
459
+ version,
460
+ error: describeCommandFailure(error),
461
+ });
462
+ return false;
463
+ }
464
+ const restored = await measure(runtime);
465
+ return restored.version === version;
466
+ }
467
+ export const AGENT_INSTALL_INTERNALS = {
468
+ INSTALL_TIMEOUT_MS,
469
+ MIN_FREE_BYTES,
470
+ downloadToFile,
471
+ installEnv,
472
+ nativeVersionsOnDisk,
473
+ npmInstallArgs,
474
+ };
475
+ //# sourceMappingURL=agent-install.js.map