@openagents-org/agent-launcher 0.2.169 → 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/package.json +1 -1
- package/registry.json +4 -2
- package/src/daemon.js +21 -6
- package/src/install-preflight.js +233 -0
- package/src/installer.js +139 -2
- package/src/node-shims.js +103 -0
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -450,7 +450,8 @@
|
|
|
450
450
|
]
|
|
451
451
|
},
|
|
452
452
|
"check_ready": {
|
|
453
|
-
"creds_file": "~/.gemini/
|
|
453
|
+
"creds_file": "~/.gemini/google_accounts.json",
|
|
454
|
+
"creds_key": "active",
|
|
454
455
|
"creds_no_parse": true,
|
|
455
456
|
"creds_path_env": [
|
|
456
457
|
"GOOGLE_APPLICATION_CREDENTIALS"
|
|
@@ -821,7 +822,7 @@
|
|
|
821
822
|
"builtin": true,
|
|
822
823
|
"install": {
|
|
823
824
|
"binary": "hermes",
|
|
824
|
-
"requires": ["python3"],
|
|
825
|
+
"requires": ["python3", "git"],
|
|
825
826
|
"macos": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
|
|
826
827
|
"linux": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
|
|
827
828
|
"windows": "\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -Command \"Invoke-RestMethod -UseBasicParsing 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' | Invoke-Expression\""
|
|
@@ -831,6 +832,7 @@
|
|
|
831
832
|
},
|
|
832
833
|
"check_ready": {
|
|
833
834
|
"creds_file": "~/.hermes/config.yaml",
|
|
835
|
+
"creds_no_parse": true,
|
|
834
836
|
"status_command": "hermes status",
|
|
835
837
|
"login_command": "hermes setup",
|
|
836
838
|
"not_ready_message": "Hermes not configured — run: hermes setup"
|
package/src/daemon.js
CHANGED
|
@@ -7,6 +7,18 @@ const os = require('os');
|
|
|
7
7
|
const { WorkspaceClient } = require('./workspace-client');
|
|
8
8
|
const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Mask an API key for display (same shape the workspace backend uses for
|
|
12
|
+
* cloud agents): enough to recognize which key it is, never enough to use it.
|
|
13
|
+
* The partial reveal needs a real remainder to hide: first4+last4 of a
|
|
14
|
+
* 9-to-12-char key would expose most of it, so anything that short is
|
|
15
|
+
* fully masked.
|
|
16
|
+
*/
|
|
17
|
+
function maskApiKey(key) {
|
|
18
|
+
if (!key || key.length <= 12) return '****';
|
|
19
|
+
return key.slice(0, 4) + '...' + key.slice(-4);
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
/**
|
|
11
23
|
* Agent process lifecycle manager.
|
|
12
24
|
*
|
|
@@ -131,18 +143,21 @@ class Daemon {
|
|
|
131
143
|
for (const a of this.config.getAgents()) {
|
|
132
144
|
if (!this._agentOnNodeWorkspace(a, node)) continue;
|
|
133
145
|
const proc = this._processes[a.name];
|
|
134
|
-
// Model: per-agent env override, else the type-level saved
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
146
|
+
// Model/key: per-agent env override, else the type-level saved env.
|
|
147
|
+
// Both power the workspace agent cards; the key goes out MASKED only,
|
|
148
|
+
// so the edit form can show a key is configured without the secret
|
|
149
|
+
// ever leaving the device.
|
|
150
|
+
let typeEnv = {};
|
|
151
|
+
try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
|
|
152
|
+
const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
|
|
153
|
+
const apiKey = (a.env && a.env.LLM_API_KEY) || typeEnv.LLM_API_KEY || null;
|
|
140
154
|
roster.push({
|
|
141
155
|
name: a.name,
|
|
142
156
|
type: a.type || 'unknown',
|
|
143
157
|
status: (proc && proc.state) || 'stopped',
|
|
144
158
|
model: model || null,
|
|
145
159
|
workingDir: a.path || null,
|
|
160
|
+
apiKeyMasked: apiKey ? maskApiKey(apiKey) : null,
|
|
146
161
|
});
|
|
147
162
|
}
|
|
148
163
|
} catch {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-flight checks for install commands that shell out to a third-party script.
|
|
3
|
+
*
|
|
4
|
+
* `curl … | bash` installers are black boxes: whatever they decide to do about a
|
|
5
|
+
* missing dependency happens with no way for us to explain it, cancel it, or
|
|
6
|
+
* even show a useful progress line. Hermes is the case that forced this file to
|
|
7
|
+
* exist. Its install.sh needs git to clone the repo, and on a mac with no
|
|
8
|
+
* developer tools it:
|
|
9
|
+
* 1. runs `git --version`, which on macOS is a SHIM that opens the system
|
|
10
|
+
* "The 'git' command requires the command line developer tools" dialog —
|
|
11
|
+
* a dialog with no OpenAgents branding, appearing seconds after the user
|
|
12
|
+
* pressed Install in our marketplace;
|
|
13
|
+
* 2. calls `xcode-select --install`, opening it a second time;
|
|
14
|
+
* 3. sleeps in a 5-second poll loop for up to 900 SECONDS waiting for git to
|
|
15
|
+
* appear, printing one line per minute.
|
|
16
|
+
* In the launcher that reads as a progress bar frozen on "downloading" for a
|
|
17
|
+
* quarter of an hour, so users retry, which starts the whole thing again.
|
|
18
|
+
*
|
|
19
|
+
* Catching the missing dependency BEFORE spawning the installer turns all of
|
|
20
|
+
* that into one actionable message.
|
|
21
|
+
*
|
|
22
|
+
* Only dependencies with a probe below are enforced. `install.requires` is
|
|
23
|
+
* hand-maintained and partly aspirational — hermes lists python3 even though
|
|
24
|
+
* its installer provisions Python through uv when none is found — so an unknown
|
|
25
|
+
* entry is left to the installer rather than blocking an install that would
|
|
26
|
+
* have worked.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { execFileSync } = require('child_process');
|
|
34
|
+
|
|
35
|
+
const IS_MACOS = process.platform === 'darwin';
|
|
36
|
+
|
|
37
|
+
/** Directories on PATH, minus any the caller wants skipped. */
|
|
38
|
+
function pathDirs(exclude = []) {
|
|
39
|
+
const raw = process.env.PATH || '';
|
|
40
|
+
return raw
|
|
41
|
+
.split(path.delimiter)
|
|
42
|
+
.map((d) => d.trim())
|
|
43
|
+
.filter((d) => d && !exclude.includes(d));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function existsIn(dirs, name, exists, isWindows) {
|
|
47
|
+
const names = isWindows ? [`${name}.exe`, `${name}.cmd`, name] : [name];
|
|
48
|
+
for (const dir of dirs) {
|
|
49
|
+
for (const n of names) {
|
|
50
|
+
const full = path.join(dir, n);
|
|
51
|
+
try {
|
|
52
|
+
if (exists(full)) return full;
|
|
53
|
+
} catch {
|
|
54
|
+
/* unreadable dir on PATH — skip */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The active developer directory, or null when none is selected. `xcode-select
|
|
63
|
+
* -p` is safe to run with no tools installed: it exits 2 and prints to stderr
|
|
64
|
+
* WITHOUT opening the install dialog. Running `git` is what opens it.
|
|
65
|
+
*/
|
|
66
|
+
function activeDeveloperDir() {
|
|
67
|
+
try {
|
|
68
|
+
return (
|
|
69
|
+
execFileSync('xcode-select', ['-p'], {
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
timeout: 5000,
|
|
72
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
73
|
+
}).trim() || null
|
|
74
|
+
);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Is a REAL git available?
|
|
82
|
+
*
|
|
83
|
+
* The hard constraint on macOS: never execute `git`, and never execute
|
|
84
|
+
* `/usr/bin/git` in particular. That file exists on every mac whether or not
|
|
85
|
+
* the Command Line Tools are installed, and running it is precisely what opens
|
|
86
|
+
* the install dialog we are trying to get ahead of. So this probe only ever
|
|
87
|
+
* touches the filesystem plus `xcode-select -p`, which reports the active
|
|
88
|
+
* developer directory (exit code 2, no dialog, when there is none).
|
|
89
|
+
*
|
|
90
|
+
* Two platforms are deliberately exempt, because on them a missing git is
|
|
91
|
+
* something the installer genuinely recovers from:
|
|
92
|
+
*
|
|
93
|
+
* - **Windows.** install.ps1's Install-Git downloads PortableGit from
|
|
94
|
+
* github.com into %LOCALAPPDATA%\hermes\git — no admin rights, no dialog, no
|
|
95
|
+
* winget. It is slow (the script's own comment budgets five minutes) and it
|
|
96
|
+
* can fail on a restricted network, but blocking the install would break the
|
|
97
|
+
* many machines where it works. What Windows needs from us is a readable
|
|
98
|
+
* error when that download fails, not a refusal — see userFacingInstallError
|
|
99
|
+
* in the launcher.
|
|
100
|
+
* - **Linux as root.** install.sh shells out to apt/dnf/pacman, which succeeds
|
|
101
|
+
* without a password prompt when we are already root. As a normal user the
|
|
102
|
+
* sudo call has no tty to ask on and fails, so the refusal stands there.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} [deps] - seams for tests; production passes nothing.
|
|
105
|
+
*/
|
|
106
|
+
function probeGit(deps = {}) {
|
|
107
|
+
const platform = deps.platform || process.platform;
|
|
108
|
+
const exists = deps.exists || ((p) => fs.existsSync(p));
|
|
109
|
+
const dirs = deps.dirs || null;
|
|
110
|
+
|
|
111
|
+
if (platform === 'win32') {
|
|
112
|
+
return { ok: true, detail: 'installer provisions PortableGit itself' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (platform !== 'darwin') {
|
|
116
|
+
const search = dirs || [...pathDirs(), '/usr/bin', '/usr/local/bin'];
|
|
117
|
+
const found = existsIn(search, 'git', exists, false);
|
|
118
|
+
if (found) return { ok: true, detail: found };
|
|
119
|
+
const uid = deps.uid !== undefined ? deps.uid : (process.getuid ? process.getuid() : null);
|
|
120
|
+
if (uid === 0) return { ok: true, detail: 'root can install git unattended' };
|
|
121
|
+
return { ok: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A git outside /usr/bin (Homebrew, MacPorts, a manual install) is a real
|
|
125
|
+
// binary and safe to trust without running it.
|
|
126
|
+
const search = dirs || [
|
|
127
|
+
...pathDirs(['/usr/bin']),
|
|
128
|
+
'/opt/homebrew/bin',
|
|
129
|
+
'/usr/local/bin',
|
|
130
|
+
];
|
|
131
|
+
const found = existsIn(search, 'git', exists, false);
|
|
132
|
+
if (found) return { ok: true, detail: found };
|
|
133
|
+
|
|
134
|
+
// Otherwise the only candidate left is the /usr/bin shim, which forwards to
|
|
135
|
+
// the active developer directory. Resolve that directory and look for the git
|
|
136
|
+
// it would forward to: present means the shim works, absent means running it
|
|
137
|
+
// would open the dialog — which is exactly the case we refuse to walk into.
|
|
138
|
+
const developerDir =
|
|
139
|
+
'developerDir' in deps ? deps.developerDir : activeDeveloperDir();
|
|
140
|
+
|
|
141
|
+
const candidates = [];
|
|
142
|
+
if (developerDir) candidates.push(path.join(developerDir, 'usr', 'bin', 'git'));
|
|
143
|
+
candidates.push('/Library/Developer/CommandLineTools/usr/bin/git');
|
|
144
|
+
for (const c of candidates) {
|
|
145
|
+
try {
|
|
146
|
+
if (exists(c)) return { ok: true, detail: c };
|
|
147
|
+
} catch {
|
|
148
|
+
/* ignore */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { ok: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* How the user fixes each missing dependency, phrased for the install screen.
|
|
157
|
+
* `action` is a hint the launcher UI can turn into a button; everything else
|
|
158
|
+
* degrades to plain text.
|
|
159
|
+
*/
|
|
160
|
+
function gitRemedy() {
|
|
161
|
+
if (IS_MACOS) {
|
|
162
|
+
return {
|
|
163
|
+
name: 'git',
|
|
164
|
+
action: 'install-xcode-clt',
|
|
165
|
+
summary: 'Git is required, and it comes with the Xcode Command Line Tools.',
|
|
166
|
+
command: 'xcode-select --install',
|
|
167
|
+
alternative: 'brew install git',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
name: 'git',
|
|
172
|
+
action: null,
|
|
173
|
+
summary: 'Git is required to download this agent.',
|
|
174
|
+
command: 'sudo apt install git # or dnf / pacman, per your distro',
|
|
175
|
+
alternative: null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const PROBES = {
|
|
180
|
+
git: { probe: probeGit, remedy: gitRemedy },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check a registry entry's `install.requires` against the machine.
|
|
185
|
+
* @returns {{ ok: boolean, missing: Array<{name: string, action: string|null, summary: string, command: string, alternative: string|null}> }}
|
|
186
|
+
*/
|
|
187
|
+
function checkInstallPrereqs(entry) {
|
|
188
|
+
const requires = entry && entry.install ? entry.install.requires : null;
|
|
189
|
+
if (!Array.isArray(requires) || requires.length === 0) return { ok: true, missing: [] };
|
|
190
|
+
|
|
191
|
+
const missing = [];
|
|
192
|
+
for (const name of requires) {
|
|
193
|
+
const spec = PROBES[String(name || '').toLowerCase()];
|
|
194
|
+
if (!spec) continue; // no probe → the installer's problem, not ours
|
|
195
|
+
let result;
|
|
196
|
+
try {
|
|
197
|
+
result = spec.probe();
|
|
198
|
+
} catch {
|
|
199
|
+
continue; // a probe that throws must never block an install
|
|
200
|
+
}
|
|
201
|
+
if (!result || !result.ok) missing.push(spec.remedy());
|
|
202
|
+
}
|
|
203
|
+
return { ok: missing.length === 0, missing };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The error thrown in place of running the installer. Carries `code` and
|
|
208
|
+
* `missing` so the launcher can render buttons instead of parsing prose, and a
|
|
209
|
+
* message that stands on its own for the CLI and the log.
|
|
210
|
+
*/
|
|
211
|
+
function missingPrereqError(agentLabel, missing) {
|
|
212
|
+
const lines = [
|
|
213
|
+
`${agentLabel} cannot be installed yet — a required tool is missing.`,
|
|
214
|
+
'',
|
|
215
|
+
];
|
|
216
|
+
for (const item of missing) {
|
|
217
|
+
lines.push(`• ${item.summary}`);
|
|
218
|
+
lines.push(` Install it with: ${item.command}`);
|
|
219
|
+
if (item.alternative) lines.push(` Or: ${item.alternative}`);
|
|
220
|
+
}
|
|
221
|
+
lines.push('', 'Once it is installed, come back and press Install again.');
|
|
222
|
+
|
|
223
|
+
const err = new Error(lines.join('\n'));
|
|
224
|
+
err.code = 'MISSING_PREREQ';
|
|
225
|
+
err.missing = missing;
|
|
226
|
+
return err;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
checkInstallPrereqs,
|
|
231
|
+
missingPrereqError,
|
|
232
|
+
probeGit,
|
|
233
|
+
};
|
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;
|
|
@@ -868,6 +890,17 @@ class Installer {
|
|
|
868
890
|
}
|
|
869
891
|
return 'absent';
|
|
870
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
|
+
}
|
|
871
904
|
return st.size > 0 ? 'present' : 'absent';
|
|
872
905
|
} catch {
|
|
873
906
|
return 'unreadable';
|
|
@@ -965,6 +998,8 @@ class Installer {
|
|
|
965
998
|
return { success: true, output: `${entry.label || agentType} uses direct API mode; no binary install needed.` };
|
|
966
999
|
}
|
|
967
1000
|
|
|
1001
|
+
this._assertPrereqs(agentType, entry);
|
|
1002
|
+
|
|
968
1003
|
let cmd = this._getInstallCommand(entry.install);
|
|
969
1004
|
if (!cmd) {
|
|
970
1005
|
throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
|
|
@@ -1034,6 +1069,8 @@ class Installer {
|
|
|
1034
1069
|
return { success: true, command: 'api-only' };
|
|
1035
1070
|
}
|
|
1036
1071
|
|
|
1072
|
+
this._assertPrereqs(agentType, entry, onData);
|
|
1073
|
+
|
|
1037
1074
|
let rawCmd = this._getInstallCommand(entry.install);
|
|
1038
1075
|
if (!rawCmd) {
|
|
1039
1076
|
throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
|
|
@@ -1116,14 +1153,30 @@ class Installer {
|
|
|
1116
1153
|
|
|
1117
1154
|
return new Promise((resolve, reject) => {
|
|
1118
1155
|
// windowsHide stops a console window from flashing up on every install.
|
|
1119
|
-
|
|
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
|
+
});
|
|
1120
1171
|
|
|
1121
1172
|
if (proc.stdout) proc.stdout.setEncoding('utf-8');
|
|
1122
1173
|
if (proc.stderr) proc.stderr.setEncoding('utf-8');
|
|
1123
1174
|
|
|
1124
1175
|
let outputTail = '';
|
|
1176
|
+
let lastOutputAt = Date.now();
|
|
1125
1177
|
const captureOutput = (d) => {
|
|
1126
1178
|
const text = String(d);
|
|
1179
|
+
lastOutputAt = Date.now();
|
|
1127
1180
|
outputTail = (outputTail + text).slice(-4000);
|
|
1128
1181
|
if (onData) onData(text);
|
|
1129
1182
|
};
|
|
@@ -1131,8 +1184,40 @@ class Installer {
|
|
|
1131
1184
|
if (proc.stdout) proc.stdout.on('data', captureOutput);
|
|
1132
1185
|
if (proc.stderr) proc.stderr.on('data', captureOutput);
|
|
1133
1186
|
|
|
1134
|
-
|
|
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); });
|
|
1135
1217
|
proc.on('close', (code) => {
|
|
1218
|
+
clearStallTimer();
|
|
1219
|
+
// The watchdog already rejected; this close is the kill it performed.
|
|
1220
|
+
if (stalled) return;
|
|
1136
1221
|
if (code === 0) {
|
|
1137
1222
|
// Aider-only: confirm a real, genuine binary exists before recording
|
|
1138
1223
|
// the install (verify-before-mark; never writes a marker on
|
|
@@ -1170,6 +1255,13 @@ class Installer {
|
|
|
1170
1255
|
return;
|
|
1171
1256
|
}
|
|
1172
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`);
|
|
1173
1265
|
}
|
|
1174
1266
|
// Cursor-only: same failure shape as hermes above — its installer
|
|
1175
1267
|
// exits 0 after a failed download. See _verifyCursorBinary.
|
|
@@ -1350,6 +1442,51 @@ class Installer {
|
|
|
1350
1442
|
});
|
|
1351
1443
|
}
|
|
1352
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
|
+
|
|
1353
1490
|
/**
|
|
1354
1491
|
* Get install command for current platform.
|
|
1355
1492
|
*/
|
|
@@ -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 };
|