@openagents-org/agent-launcher 0.2.168 → 0.2.170

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/src/installer.js CHANGED
@@ -8,10 +8,32 @@ const { whichBinary, getEnhancedEnv, getRuntimePrefix, clearBinaryLookupCache, a
8
8
  const { EnvManager } = require('./env');
9
9
  const { nodeDistUrls, installRegistry } = require('./mirrors');
10
10
  const { readinessReason, REASON } = require('./adapters/health-status');
11
+ const { checkInstallPrereqs, missingPrereqError } = require('./install-preflight');
12
+ const { detectShadowedNodeShims, shadowedNodeWarning } = require('./node-shims');
11
13
 
12
14
  const STATUS_CACHE_TTL_MS = 10000;
13
15
  const statusCache = new Map();
14
16
 
17
+ /**
18
+ * How long a streaming install may produce NO output before it is treated as
19
+ * hung and killed. Generous on purpose: a slow npm postinstall or a large
20
+ * extraction can legitimately go quiet for a while, and killing a working
21
+ * install is worse than waiting. Set OPENAGENTS_INSTALL_STALL_MS=0 to disable.
22
+ */
23
+ const INSTALL_STALL_MS = (() => {
24
+ const raw = process.env.OPENAGENTS_INSTALL_STALL_MS;
25
+ if (raw === undefined || raw === '') return 10 * 60 * 1000;
26
+ const n = Number(raw);
27
+ return Number.isFinite(n) && n >= 0 ? n : 10 * 60 * 1000;
28
+ })();
29
+
30
+ /**
31
+ * How often the stall watchdog checks. Capped at half the stall budget so the
32
+ * poll granularity never dominates the deadline (matters for a short budget in
33
+ * tests; in production the 15s ceiling applies).
34
+ */
35
+ const STALL_POLL_MS = Math.max(50, Math.min(15000, Math.floor(INSTALL_STALL_MS / 2) || 15000));
36
+
15
37
  // Version detection is comparatively expensive (spawns `<bin> --version`), so
16
38
  // results are cached briefly and invalidated on install/uninstall.
17
39
  const VERSION_CACHE_TTL_MS = 60000;
@@ -609,7 +631,12 @@ class Installer {
609
631
  env: getEnhancedEnv(),
610
632
  timeout: 10000,
611
633
  }).trim();
612
- const match = raw.match(/(\d+[\d.]+\d+)/);
634
+ // The optional `-prerelease` tail is NOT decoration. An agent pinned to a
635
+ // preview release (`@deepseek-ai/dsh@0.1.0-rc.6`) is gated on the exact
636
+ // version, and dropping the suffix reported a correctly installed rc.6 as
637
+ // plain `0.1.0` — which then failed its own gate. Agents without a
638
+ // prerelease segment match exactly as before.
639
+ const match = raw.match(/(\d+[\d.]+\d+(?:-[0-9A-Za-z.-]+)?)/);
613
640
  version = match ? match[1] : (raw.split('\n')[0] || null);
614
641
  } catch {}
615
642
 
@@ -618,14 +645,52 @@ class Installer {
618
645
  }
619
646
 
620
647
  /**
621
- * Evaluate `install.min_version` against a detected version. Generic for any
648
+ * Evaluate a registry version gate against a detected version. Generic for any
622
649
  * agent. Returns { compatible: true|false|null, minVersion, message }.
623
- * • no min_version declared → compatible:true (gate disabled)
624
- * • version unparseable → compatible:null ("unknown", don't false-pass)
625
- * • version < min_version → compatible:false (block + upgrade hint)
626
- * • version >= min_version → compatible:true
650
+ *
651
+ * Two gate shapes, in priority order:
652
+ *
653
+ * `install.supported_version` — EXACT match. For agents pinned to an
654
+ * upstream preview whose configuration can change between releases, a floor
655
+ * is the wrong shape: it happily admits the next preview, which is precisely
656
+ * the version the adapter has not been written against. Declared by
657
+ * deepseek (dsh is a developer preview).
658
+ *
659
+ * `install.min_version` / `check_ready.min_version` — a floor (>=). The
660
+ * original behaviour, unchanged for every agent that does not declare
661
+ * supported_version.
627
662
  */
628
663
  _evaluateCompatibility(entry, version) {
664
+ const supported = (entry && entry.install && entry.install.supported_version) || null;
665
+ if (supported) {
666
+ // Compare the version-shaped token from each side, prerelease included.
667
+ // `compareVersions` cannot be used: it strips `-rc.N`, so rc.5 and rc.6
668
+ // would compare equal and the exact gate would admit the wrong preview.
669
+ const norm = (v) => {
670
+ const m = String(v == null ? '' : v).match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
671
+ return m ? m[0] : null;
672
+ };
673
+ const got = norm(version);
674
+ const want = norm(supported);
675
+ if (!got) {
676
+ return {
677
+ compatible: null,
678
+ minVersion: supported,
679
+ message: `Version unknown (requires exactly ${supported})`,
680
+ };
681
+ }
682
+ if (got !== want) {
683
+ return {
684
+ compatible: false,
685
+ minVersion: supported,
686
+ message:
687
+ `${(entry.label || entry.name || 'This agent')} ${got} is not supported — ` +
688
+ `it is pinned to ${want} exactly (upstream ships it as a developer preview).`,
689
+ };
690
+ }
691
+ return { compatible: true, minVersion: supported, message: null };
692
+ }
693
+
629
694
  // Opt-in floor declared under either install.min_version or
630
695
  // check_ready.min_version (e.g. cline pins check_ready.min_version).
631
696
  const minVersion = (entry && (
@@ -825,6 +890,17 @@ class Installer {
825
890
  }
826
891
  return 'absent';
827
892
  }
893
+ // A named field, for a file that exists in both states. Gemini's
894
+ // google_accounts.json is written on install and records a signed-OUT
895
+ // account as `active: null`, so its existence proves nothing — only the
896
+ // field does. Still content-free: the value is tested for emptiness, never
897
+ // read out, logged or returned.
898
+ if (checkReady.creds_key) {
899
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
900
+ const value = parsed && typeof parsed === 'object' ? parsed[checkReady.creds_key] : null;
901
+ const filled = typeof value === 'string' ? !!value.trim() : !!value;
902
+ return filled ? 'present' : 'absent';
903
+ }
828
904
  return st.size > 0 ? 'present' : 'absent';
829
905
  } catch {
830
906
  return 'unreadable';
@@ -922,6 +998,8 @@ class Installer {
922
998
  return { success: true, output: `${entry.label || agentType} uses direct API mode; no binary install needed.` };
923
999
  }
924
1000
 
1001
+ this._assertPrereqs(agentType, entry);
1002
+
925
1003
  let cmd = this._getInstallCommand(entry.install);
926
1004
  if (!cmd) {
927
1005
  throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
@@ -991,6 +1069,8 @@ class Installer {
991
1069
  return { success: true, command: 'api-only' };
992
1070
  }
993
1071
 
1072
+ this._assertPrereqs(agentType, entry, onData);
1073
+
994
1074
  let rawCmd = this._getInstallCommand(entry.install);
995
1075
  if (!rawCmd) {
996
1076
  throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
@@ -1073,14 +1153,30 @@ class Installer {
1073
1153
 
1074
1154
  return new Promise((resolve, reject) => {
1075
1155
  // windowsHide stops a console window from flashing up on every install.
1076
- const proc = spawn(spawnFile, spawnArgs, { shell: useShell, env, cwd: installCwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
1156
+ //
1157
+ // detached (Unix): puts the installer in its own process group, which is
1158
+ // what makes the stall watchdog able to stop the WHOLE tree. Without it
1159
+ // the direct child is a shell and the actual work — curl, git, uv — runs
1160
+ // in grandchildren that survive a signal aimed at the shell. It does not
1161
+ // change lifetime semantics: an install was never killed by the launcher
1162
+ // exiting, before or after this.
1163
+ const proc = spawn(spawnFile, spawnArgs, {
1164
+ shell: useShell,
1165
+ env,
1166
+ cwd: installCwd,
1167
+ stdio: ['ignore', 'pipe', 'pipe'],
1168
+ windowsHide: true,
1169
+ detached: process.platform !== 'win32',
1170
+ });
1077
1171
 
1078
1172
  if (proc.stdout) proc.stdout.setEncoding('utf-8');
1079
1173
  if (proc.stderr) proc.stderr.setEncoding('utf-8');
1080
1174
 
1081
1175
  let outputTail = '';
1176
+ let lastOutputAt = Date.now();
1082
1177
  const captureOutput = (d) => {
1083
1178
  const text = String(d);
1179
+ lastOutputAt = Date.now();
1084
1180
  outputTail = (outputTail + text).slice(-4000);
1085
1181
  if (onData) onData(text);
1086
1182
  };
@@ -1088,8 +1184,40 @@ class Installer {
1088
1184
  if (proc.stdout) proc.stdout.on('data', captureOutput);
1089
1185
  if (proc.stderr) proc.stderr.on('data', captureOutput);
1090
1186
 
1091
- proc.on('error', (err) => reject(err));
1187
+ // Stall watchdog. A third-party installer that decides to wait — hermes's
1188
+ // install.sh polls for up to 900s after asking macOS for the developer
1189
+ // tools — otherwise hangs this promise indefinitely, and the only visible
1190
+ // symptom is a progress bar that stopped moving. The preflight now
1191
+ // prevents that specific case, but the general shape (a download that
1192
+ // never returns, a prompt on a stdin nobody is reading) is not specific
1193
+ // to any one installer, so time is bounded here for all of them.
1194
+ let stallTimer = null;
1195
+ let stalled = false;
1196
+ const clearStallTimer = () => {
1197
+ if (stallTimer) { clearInterval(stallTimer); stallTimer = null; }
1198
+ };
1199
+ if (INSTALL_STALL_MS > 0) {
1200
+ stallTimer = setInterval(() => {
1201
+ if (Date.now() - lastOutputAt < INSTALL_STALL_MS) return;
1202
+ stalled = true;
1203
+ clearStallTimer();
1204
+ const minutes = Math.round(INSTALL_STALL_MS / 60000);
1205
+ const msg = `Install stalled: no output for ${minutes} minutes. `
1206
+ + 'The installer was stopped. It may be waiting on a prompt, or on a '
1207
+ + 'download that never returns.';
1208
+ if (onData) onData(`\n${msg}\n`);
1209
+ this._killProcessTree(proc);
1210
+ reject(new Error(msg));
1211
+ }, STALL_POLL_MS);
1212
+ // Never hold the event loop open for the watchdog alone.
1213
+ if (typeof stallTimer.unref === 'function') stallTimer.unref();
1214
+ }
1215
+
1216
+ proc.on('error', (err) => { clearStallTimer(); reject(err); });
1092
1217
  proc.on('close', (code) => {
1218
+ clearStallTimer();
1219
+ // The watchdog already rejected; this close is the kill it performed.
1220
+ if (stalled) return;
1093
1221
  if (code === 0) {
1094
1222
  // Aider-only: confirm a real, genuine binary exists before recording
1095
1223
  // the install (verify-before-mark; never writes a marker on
@@ -1127,6 +1255,13 @@ class Installer {
1127
1255
  return;
1128
1256
  }
1129
1257
  if (onData) onData(`\nHermes CLI resolved: ${hermes.path}\n`);
1258
+ // Its installer may have repointed the machine's node/npm/npx at
1259
+ // its own Node. See node-shims.js — we report, never rewrite.
1260
+ const shimWarning = shadowedNodeWarning(
1261
+ entry.label || agentType,
1262
+ detectShadowedNodeShims(),
1263
+ );
1264
+ if (shimWarning && onData) onData(`${shimWarning}\n`);
1130
1265
  }
1131
1266
  // Cursor-only: same failure shape as hermes above — its installer
1132
1267
  // exits 0 after a failed download. See _verifyCursorBinary.
@@ -1307,6 +1442,51 @@ class Installer {
1307
1442
  });
1308
1443
  }
1309
1444
 
1445
+ /**
1446
+ * Refuse to run a third-party installer whose hard dependencies are missing.
1447
+ *
1448
+ * See install-preflight.js for why this is worth doing before the spawn: the
1449
+ * scripts we shell out to handle a missing dependency by opening OS dialogs
1450
+ * and sleeping for minutes, which the launcher can only render as a frozen
1451
+ * progress bar.
1452
+ */
1453
+ _assertPrereqs(agentType, entry, onData, check = checkInstallPrereqs) {
1454
+ const { ok, missing } = check(entry);
1455
+ if (ok) return;
1456
+ const err = missingPrereqError(entry.label || agentType, missing);
1457
+ if (onData) onData(`${err.message}\n`);
1458
+ throw err;
1459
+ }
1460
+
1461
+ /**
1462
+ * Stop a spawned installer and everything it started.
1463
+ *
1464
+ * The child is usually a shell (`curl … | bash`, powershell) whose real work
1465
+ * happens in grandchildren, so signalling the direct child alone leaves the
1466
+ * download running. On Unix the process group is signalled; on Windows
1467
+ * taskkill /T walks the tree.
1468
+ */
1469
+ _killProcessTree(proc) {
1470
+ if (!proc || proc.exitCode !== null) return;
1471
+ try {
1472
+ if (process.platform === 'win32') {
1473
+ try {
1474
+ execSync(`taskkill /F /T /PID ${proc.pid}`, {
1475
+ stdio: 'ignore', timeout: 5000, windowsHide: true,
1476
+ });
1477
+ } catch { proc.kill(); }
1478
+ return;
1479
+ }
1480
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
1481
+ setTimeout(() => {
1482
+ if (proc.exitCode !== null) return;
1483
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch { proc.kill('SIGKILL'); }
1484
+ }, 5000).unref();
1485
+ } catch {
1486
+ /* the process is already gone */
1487
+ }
1488
+ }
1489
+
1310
1490
  /**
1311
1491
  * Get install command for current platform.
1312
1492
  */
@@ -1326,11 +1506,18 @@ class Installer {
1326
1506
  // npm install -g <pkg> → npm uninstall --prefix <runtimeDir> <pkg>
1327
1507
  if (installCmd.includes('npm install')) {
1328
1508
  const prefixDir = agentType ? getRuntimePrefix(agentType) : path.join(os.homedir(), '.openagents', 'nodejs');
1329
- return installCmd
1509
+ const cmd = installCmd
1330
1510
  .replace('npm install -g', `npm uninstall --prefix "${prefixDir}"`)
1331
- .replace('npm install', 'npm uninstall')
1332
- .replace(/@latest/g, '')
1333
- .replace(/@[\d.]+/g, '');
1511
+ .replace('npm install', 'npm uninstall');
1512
+ // Drop the version spec, whatever shape it takes. This used to be two
1513
+ // blind global replaces — `@latest` and `@[\d.]+` — and the second one
1514
+ // understood only digits and dots, so a PRERELEASE pin was cut in half:
1515
+ // @deepseek-ai/dsh@0.1.0-rc.6 → @deepseek-ai/dsh-rc.6
1516
+ // npm exits 0 when asked to remove a package that is not installed, so
1517
+ // the uninstall reported success, removed nothing, and left the agent
1518
+ // looking installed forever. Anchoring to the package token at the end of
1519
+ // the command makes the shape of the version irrelevant.
1520
+ return cmd.replace(/(\s@?[\w-]+(?:\/[\w-]+)?)@\S+\s*$/, '$1');
1334
1521
  }
1335
1522
 
1336
1523
  // pip install <pkg> → pip uninstall -y <pkg>
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Detecting an agent installer that has taken over the machine's `node`.
3
+ *
4
+ * Hermes's install.sh, when it decides the system Node is too old, downloads
5
+ * its own and then does this:
6
+ *
7
+ * ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
8
+ * ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
9
+ * ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx"
10
+ *
11
+ * `ln -sf` overwrites. On a machine where ~/.local/bin comes before nvm or
12
+ * Homebrew on PATH — a very common layout — the user's default `node` silently
13
+ * becomes the agent's, for every shell and every unrelated project, and npm's
14
+ * global prefix is rewritten to ~/.local along with it. Nothing announces this,
15
+ * and nothing connects a project that suddenly builds differently back to
16
+ * having installed an agent.
17
+ *
18
+ * This module only looks and reports. Rewriting a user's PATH or deleting their
19
+ * symlinks would be a second uninvited change on top of the first, and the
20
+ * agent's Node may be exactly what they want. Telling them precisely what
21
+ * happened, and how to undo it, is the part that was missing.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+
30
+ /**
31
+ * node/npm/npx under `linkDir` that are symlinks into `ownerDir`, together with
32
+ * whether they shadow another installation the user already had.
33
+ *
34
+ * @param {object} [deps] - seams for tests; production passes nothing.
35
+ * @returns {{ shims: Array<{name: string, link: string, target: string}>, shadowed: string|null }}
36
+ */
37
+ function detectShadowedNodeShims(deps = {}) {
38
+ const home = deps.home || os.homedir();
39
+ const linkDir = deps.linkDir || path.join(home, '.local', 'bin');
40
+ const ownerDir = deps.ownerDir || path.join(home, '.hermes', 'node', 'bin');
41
+ const readLink = deps.readLink || defaultReadLink;
42
+ const exists = deps.exists || ((p) => fs.existsSync(p));
43
+ const entries = deps.pathEntries || (process.env.PATH || '').split(path.delimiter);
44
+
45
+ const shims = [];
46
+ for (const name of ['node', 'npm', 'npx']) {
47
+ const link = path.join(linkDir, name);
48
+ const target = readLink(link);
49
+ if (!target) continue;
50
+ const resolved = path.resolve(linkDir, target);
51
+ if (resolved === path.join(ownerDir, name) || resolved.startsWith(ownerDir + path.sep)) {
52
+ shims.push({ name, link, target: resolved });
53
+ }
54
+ }
55
+ if (shims.length === 0) return { shims: [], shadowed: null };
56
+
57
+ // Only a problem if the user HAD another node. On a machine with none, these
58
+ // symlinks are the installer doing something useful.
59
+ const normalized = entries.map((d) => d.trim()).filter(Boolean);
60
+ const linkIndex = normalized.indexOf(linkDir);
61
+ const after = linkIndex === -1 ? normalized : normalized.slice(linkIndex + 1);
62
+ for (const dir of after) {
63
+ if (dir === linkDir) continue;
64
+ const candidate = path.join(dir, 'node');
65
+ try {
66
+ if (exists(candidate)) return { shims, shadowed: candidate };
67
+ } catch {
68
+ /* unreadable dir on PATH — skip */
69
+ }
70
+ }
71
+ return { shims, shadowed: null };
72
+ }
73
+
74
+ /** lstat + readlink, or null when the path is absent or not a symlink. */
75
+ function defaultReadLink(p) {
76
+ try {
77
+ if (!fs.lstatSync(p).isSymbolicLink()) return null;
78
+ return fs.readlinkSync(p);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The warning text, or null when there is nothing worth saying. Written to be
86
+ * read in an install log by someone who did not expect any of this.
87
+ */
88
+ function shadowedNodeWarning(agentLabel, detection) {
89
+ if (!detection || detection.shims.length === 0 || !detection.shadowed) return null;
90
+ const names = detection.shims.map((s) => s.name).join(', ');
91
+ const dir = path.dirname(detection.shims[0].link);
92
+ return [
93
+ '',
94
+ `Note: the ${agentLabel} installer replaced ${names} in ${dir} with links to its own Node.`,
95
+ `That now takes precedence over ${detection.shadowed} for every shell on this machine,`,
96
+ 'so other projects will build with the agent\'s Node version from here on.',
97
+ '',
98
+ 'To put your own Node back, remove the links (this does not affect the agent):',
99
+ ` rm -f ${detection.shims.map((s) => s.link).join(' ')}`,
100
+ ].join('\n');
101
+ }
102
+
103
+ module.exports = { detectShadowedNodeShims, shadowedNodeWarning };