@openagents-org/agent-launcher 0.2.186 → 0.2.187
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 +2 -2
- package/registry.json +13 -8
- package/src/adapters/cline.js +34 -38
- package/src/adapters/codex.js +246 -66
- package/src/adapters/hermes.js +40 -0
- package/src/adapters/llm-direct.js +2 -1
- package/src/adapters/utils.js +7 -4
- package/src/daemon.js +81 -1
- package/src/installer.js +232 -59
- package/src/model-list.js +100 -0
- package/src/paths.js +102 -0
- package/src/probe.js +23 -2
package/src/adapters/hermes.js
CHANGED
|
@@ -25,6 +25,7 @@ const { spawn, bridgeSpawn } = require('../wsl');
|
|
|
25
25
|
|
|
26
26
|
const BaseAdapter = require('./base');
|
|
27
27
|
const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
|
|
28
|
+
const { REASON } = require('./health-status');
|
|
28
29
|
const { whichBinary, whereBinary } = require('../paths');
|
|
29
30
|
|
|
30
31
|
const IS_WINDOWS = process.platform === 'win32';
|
|
@@ -123,6 +124,34 @@ class HermesAdapter extends BaseAdapter {
|
|
|
123
124
|
return null;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Preflight gate (run by the daemon before join). Hermes can do nothing
|
|
129
|
+
* without its CLI, so when none can be resolved we surface a precise
|
|
130
|
+
* 'runtime_missing' reason and skip the workspace join — instead of joining,
|
|
131
|
+
* reporting "online" and then failing every message. That contradiction is
|
|
132
|
+
* exactly what users hit: the workspace showed a green hermes and "all set"
|
|
133
|
+
* while the smoke test on the same screen said "Not installed", because
|
|
134
|
+
* `create_agent --install` treats a failed third-party install script as a
|
|
135
|
+
* warning and connects the agent anyway.
|
|
136
|
+
*
|
|
137
|
+
* Re-resolves rather than trusting the constructor's lookup: an install that
|
|
138
|
+
* landed after the daemon started must not need a restart to be seen. NOTE:
|
|
139
|
+
* 'runtime_missing' (binary gone/never landed at run time), NOT
|
|
140
|
+
* 'not_installed' — install detection lives in the installer.
|
|
141
|
+
*/
|
|
142
|
+
preflight() {
|
|
143
|
+
if (!this._hermesBin) this._hermesBin = this._findHermesBinary();
|
|
144
|
+
if (!this._hermesBin) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: REASON.RUNTIME_MISSING,
|
|
148
|
+
message: `Hermes CLI not found — install with: ${HERMES_INSTALL_HINT}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
this._log(`Hermes CLI resolved: ${this._hermesBin}`);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
126
155
|
_resolveProfile(explicit, agentName) {
|
|
127
156
|
if (explicit && explicit !== '' && explicit !== 'auto') return explicit;
|
|
128
157
|
// Match agent name to an existing ~/.hermes/profiles/<name> if present
|
|
@@ -371,6 +400,17 @@ class HermesAdapter extends BaseAdapter {
|
|
|
371
400
|
const sender = msg.senderName || msg.senderType || 'user';
|
|
372
401
|
this._log(`Processing workspace message from ${sender} in ${msgChannel}`);
|
|
373
402
|
|
|
403
|
+
// Re-resolve per message: the constructor's lookup is a snapshot, and an
|
|
404
|
+
// agent that was created before its CLI finished installing would
|
|
405
|
+
// otherwise stay broken until the daemon restarts.
|
|
406
|
+
if (!this._hermesBin) this._hermesBin = this._findHermesBinary();
|
|
407
|
+
if (!this._hermesBin) {
|
|
408
|
+
const message = `Hermes CLI not found — install with: ${HERMES_INSTALL_HINT}`;
|
|
409
|
+
this._reportStatus(REASON.RUNTIME_MISSING, message);
|
|
410
|
+
await this.sendError(msgChannel, message);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
374
414
|
await this._autoTitleChannel(msgChannel, content);
|
|
375
415
|
await this.sendStatus(msgChannel, 'thinking...');
|
|
376
416
|
|
|
@@ -158,7 +158,8 @@ class LlmDirectAdapter extends BaseAdapter {
|
|
|
158
158
|
'Authorization': `Bearer ${this._apiKey}`,
|
|
159
159
|
};
|
|
160
160
|
const payload = JSON.stringify({
|
|
161
|
-
model
|
|
161
|
+
// The model picked in the workspace, which modelLabel() reports, wins.
|
|
162
|
+
model: this.modelLabel() || 'gpt-4o',
|
|
162
163
|
messages,
|
|
163
164
|
stream: true,
|
|
164
165
|
});
|
package/src/adapters/utils.js
CHANGED
|
@@ -88,19 +88,22 @@ function formatAttachmentsForPrompt(
|
|
|
88
88
|
const url = att.url || `${base}/v1/files/${fileId}`;
|
|
89
89
|
const curl = isWindows ? 'curl.exe' : 'curl';
|
|
90
90
|
const tmpDir = isWindows ? '$env:TEMP' : '/tmp';
|
|
91
|
+
// A web upload's filename is its workspace path (uploaded_files/<stamp>_<name>)
|
|
92
|
+
// and curl -o does not create folders, so that download failed with exit 23.
|
|
93
|
+
const localName = filename.split(/[\\/]/).pop() || fileId || 'attachment';
|
|
91
94
|
if (contentType.startsWith('image/')) {
|
|
92
95
|
lines.push(
|
|
93
96
|
`- Image: ${filename} (file_id: ${fileId}) — ` +
|
|
94
97
|
`download with curl, then use your Read tool on the local file to view it:\n` +
|
|
95
|
-
` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${
|
|
96
|
-
` Step 2: Use the Read tool on ${tmpDir}/${
|
|
98
|
+
` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${localName}\n` +
|
|
99
|
+
` Step 2: Use the Read tool on ${tmpDir}/${localName} to see the image`
|
|
97
100
|
);
|
|
98
101
|
} else {
|
|
99
102
|
lines.push(
|
|
100
103
|
`- File: ${filename} (file_id: ${fileId}, type: ${contentType}) — ` +
|
|
101
104
|
`download with curl, then use your Read tool on the local file:\n` +
|
|
102
|
-
` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${
|
|
103
|
-
` Step 2: Use the Read tool on ${tmpDir}/${
|
|
105
|
+
` Step 1: ${curl} -s -H "X-Workspace-Token: ${tokenExpr}" "${url}" -o ${tmpDir}/${localName}\n` +
|
|
106
|
+
` Step 2: Use the Read tool on ${tmpDir}/${localName} to read the file`
|
|
104
107
|
);
|
|
105
108
|
}
|
|
106
109
|
} else {
|
package/src/daemon.js
CHANGED
|
@@ -9,6 +9,7 @@ const { execSync, execFileSync } = require('child_process');
|
|
|
9
9
|
const { spawn } = require('./wsl');
|
|
10
10
|
const os = require('os');
|
|
11
11
|
const { WorkspaceClient } = require('./workspace-client');
|
|
12
|
+
const { listEndpointModels } = require('./model-list');
|
|
12
13
|
const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -44,6 +45,23 @@ function filesystemRoots(platform = os.platform(), exists = fs.existsSync) {
|
|
|
44
45
|
return roots;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Hostname of a configured base URL, or null when there is none. The roster
|
|
50
|
+
* carries only this: a relay URL can hold a credential in its userinfo or
|
|
51
|
+
* path. A value written without a scheme (`localhost:4000`) still names a host.
|
|
52
|
+
*/
|
|
53
|
+
function endpointHost(value) {
|
|
54
|
+
const raw = String(value || '').trim();
|
|
55
|
+
if (!raw) return null;
|
|
56
|
+
for (const candidate of [raw, `http://${raw}`]) {
|
|
57
|
+
try {
|
|
58
|
+
const host = new URL(candidate).hostname.toLowerCase();
|
|
59
|
+
if (host) return host;
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
/**
|
|
48
66
|
* Agent process lifecycle manager.
|
|
49
67
|
*
|
|
@@ -326,7 +344,8 @@ class Daemon {
|
|
|
326
344
|
// Model/key: per-agent env override, else the type-level saved env.
|
|
327
345
|
// Both power the workspace agent cards; the key goes out MASKED only,
|
|
328
346
|
// so the edit form can show a key is configured without the secret
|
|
329
|
-
// ever leaving the device.
|
|
347
|
+
// ever leaving the device. The endpoint goes out as a hostname, so
|
|
348
|
+
// the workspace can tell a relay from the vendor its model list is for.
|
|
330
349
|
let typeEnv = {};
|
|
331
350
|
try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
|
|
332
351
|
const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
|
|
@@ -338,6 +357,7 @@ class Daemon {
|
|
|
338
357
|
model: model || null,
|
|
339
358
|
workingDir: a.path || null,
|
|
340
359
|
apiKeyMasked: apiKey ? maskApiKey(apiKey) : null,
|
|
360
|
+
baseUrlHost: endpointHost(this._configuredBaseUrl(a, typeEnv)),
|
|
341
361
|
probe: this._probes[a.name] || null,
|
|
342
362
|
});
|
|
343
363
|
}
|
|
@@ -347,6 +367,52 @@ class Daemon {
|
|
|
347
367
|
return roster;
|
|
348
368
|
}
|
|
349
369
|
|
|
370
|
+
/**
|
|
371
|
+
* The base URL this agent's CLI is pointed at, found the way _buildAgentEnv
|
|
372
|
+
* builds its env: LLM_BASE_URL or a provider variable (OPENAI_BASE_URL,
|
|
373
|
+
* ANTHROPIC_BASE_URL, …) saved for it or mapped by resolve_env, and failing
|
|
374
|
+
* those, the daemon's own environment for the provider variable this type's
|
|
375
|
+
* LLM_BASE_URL maps to, which the spawned CLI inherits.
|
|
376
|
+
*/
|
|
377
|
+
_configuredBaseUrl(a, typeEnv) {
|
|
378
|
+
const saved = { ...typeEnv, ...(a.env || {}) };
|
|
379
|
+
let env = saved;
|
|
380
|
+
try { env = { ...saved, ...this.envManager.resolve(a.type, saved, this.registry) }; } catch {}
|
|
381
|
+
const set = (v) => String(v || '').trim();
|
|
382
|
+
if (set(env.LLM_BASE_URL)) return env.LLM_BASE_URL;
|
|
383
|
+
const key = Object.keys(env).find((k) => /_BASE_URL$/.test(k) && set(env[k]));
|
|
384
|
+
if (key) return env[key];
|
|
385
|
+
let rules = [];
|
|
386
|
+
try { rules = (this.registry && this.registry.getResolveRules(a.type)) || []; } catch {}
|
|
387
|
+
const inherited = rules.find((r) => r && r.from === 'LLM_BASE_URL' && r.to && set(process.env[r.to]));
|
|
388
|
+
return inherited ? process.env[inherited.to] : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Where this agent's CLI sends requests and with which key, for listing that
|
|
393
|
+
* endpoint's models. The key is looked up the way the CLI would find it: the
|
|
394
|
+
* protocol's own variables for an Anthropic-protocol agent, else LLM_API_KEY
|
|
395
|
+
* or the variable resolve_env maps it to, inherited from the daemon or not.
|
|
396
|
+
*/
|
|
397
|
+
_agentEndpoint(a) {
|
|
398
|
+
let typeEnv = {};
|
|
399
|
+
try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
|
|
400
|
+
const env = this._buildAgentEnv(a);
|
|
401
|
+
let entry = null;
|
|
402
|
+
try { entry = this.registry.getEntry(a.type); } catch {}
|
|
403
|
+
const protocol = (entry && entry.protocol) || 'openai';
|
|
404
|
+
const rules = (entry && entry.resolve_env && entry.resolve_env.rules) || [];
|
|
405
|
+
const keyVars = protocol === 'anthropic'
|
|
406
|
+
? ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'LLM_API_KEY']
|
|
407
|
+
: ['LLM_API_KEY', ...rules.filter((r) => r && r.from === 'LLM_API_KEY').map((r) => r.to), 'OPENAI_API_KEY'];
|
|
408
|
+
const keyVar = keyVars.find((k) => k && String(env[k] || '').trim());
|
|
409
|
+
return {
|
|
410
|
+
baseUrl: this._configuredBaseUrl(a, typeEnv),
|
|
411
|
+
apiKey: keyVar ? String(env[keyVar]).trim() : '',
|
|
412
|
+
protocol,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
350
416
|
/**
|
|
351
417
|
* Filesystem hint for the working-directory picker: home, its immediate
|
|
352
418
|
* (non-hidden) subfolders, and the device's filesystem roots. Roots let a
|
|
@@ -606,6 +672,20 @@ async _runNodeCommand(n, cmd) {
|
|
|
606
672
|
this._nodeHeartbeat();
|
|
607
673
|
ok = true;
|
|
608
674
|
message = `Detected ${this._runtimes.length} runtime(s)`;
|
|
675
|
+
} else if (action === 'list_models') {
|
|
676
|
+
// The model picker asks what the agent's own endpoint serves. The
|
|
677
|
+
// request needs the key, which is why it runs here.
|
|
678
|
+
if (!name) throw new Error('Missing agent name');
|
|
679
|
+
const agent = this.config.getAgent(name);
|
|
680
|
+
if (!agent || !this._agentOnNodeWorkspace(agent, n)) {
|
|
681
|
+
throw new Error(`Agent '${name}' is not managed by this workspace`);
|
|
682
|
+
}
|
|
683
|
+
const endpoint = this._agentEndpoint(agent);
|
|
684
|
+
if (!endpoint.baseUrl) throw new Error(`Agent '${name}' uses its CLI's default endpoint`);
|
|
685
|
+
const listed = await listEndpointModels(endpoint);
|
|
686
|
+
ok = listed.models.length > 0;
|
|
687
|
+
message = ok ? `Listed ${listed.models.length} model(s)` : listed.error;
|
|
688
|
+
data = { models: listed.models };
|
|
609
689
|
} else if (action === 'list_dir') {
|
|
610
690
|
data = this._listDir(args.path);
|
|
611
691
|
ok = true;
|
package/src/installer.js
CHANGED
|
@@ -4,7 +4,17 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { execSync, exec } = require('child_process');
|
|
7
|
-
const {
|
|
7
|
+
const {
|
|
8
|
+
whichBinary,
|
|
9
|
+
getEnhancedEnv,
|
|
10
|
+
getRuntimePrefix,
|
|
11
|
+
clearBinaryLookupCache,
|
|
12
|
+
aiderBinDirs,
|
|
13
|
+
resolveBinaryInKnownDirs,
|
|
14
|
+
resolveManagedNpmBinary,
|
|
15
|
+
resolveManagedNpmPackageBin,
|
|
16
|
+
isNodeShebangScript,
|
|
17
|
+
} = require('./paths');
|
|
8
18
|
const { isWslBinary, bridgedCommandString, wslHomeUnc } = require('./wsl');
|
|
9
19
|
const { canBlock } = require('./probe-mode');
|
|
10
20
|
const { EnvManager } = require('./env');
|
|
@@ -610,6 +620,14 @@ class Installer {
|
|
|
610
620
|
if (/\.(mjs|cjs|js)$/i.test(binary)) {
|
|
611
621
|
return `"${this._nodeBinary()}" "${binary}" --version`;
|
|
612
622
|
}
|
|
623
|
+
// Same problem, no extension to spot it by: npm's package bin is often an
|
|
624
|
+
// extensionless `#!/usr/bin/env node` script (node_modules/cline/bin/cline),
|
|
625
|
+
// which is what we resolve whenever npm wrote no .cmd shim. cmd.exe cannot
|
|
626
|
+
// run it — the probe dies with "is not recognized as an internal or external
|
|
627
|
+
// command" — and the agent then shows up in the app with no version at all.
|
|
628
|
+
if (isNodeShebangScript(binary)) {
|
|
629
|
+
return `"${this._nodeBinary()}" "${binary}" --version`;
|
|
630
|
+
}
|
|
613
631
|
return `"${binary}" --version`;
|
|
614
632
|
}
|
|
615
633
|
|
|
@@ -1083,6 +1101,19 @@ class Installer {
|
|
|
1083
1101
|
throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
|
|
1084
1102
|
}
|
|
1085
1103
|
|
|
1104
|
+
let bootstrapEnv = this._buildShellEnv();
|
|
1105
|
+
let installEnv;
|
|
1106
|
+
if (agentType === 'hermes' && process.platform === 'win32') {
|
|
1107
|
+
const prepared = this._prepareWindowsPowerShellInstall(cmd, bootstrapEnv);
|
|
1108
|
+
cmd = prepared.command;
|
|
1109
|
+
bootstrapEnv = prepared.env;
|
|
1110
|
+
installEnv = prepared.env;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Same uv bootstrap the streaming path does — `agn install hermes` and the
|
|
1114
|
+
// workspace's remote create must not behave differently from the button.
|
|
1115
|
+
await this._bootstrapManagedUv(agentType, bootstrapEnv, null);
|
|
1116
|
+
|
|
1086
1117
|
// Use bundled node/npm if system npm not available
|
|
1087
1118
|
if (cmd.startsWith('npm install')) {
|
|
1088
1119
|
const prefixDir = getRuntimePrefix(agentType);
|
|
@@ -1091,7 +1122,7 @@ class Installer {
|
|
|
1091
1122
|
cmd = this._resolveNpmCommand(args);
|
|
1092
1123
|
}
|
|
1093
1124
|
|
|
1094
|
-
const output = await this._execShell(cmd);
|
|
1125
|
+
const output = await this._execShell(cmd, 300000, installEnv);
|
|
1095
1126
|
|
|
1096
1127
|
// Aider-only: the curl/uv/pipx installer can exit 0 without landing a
|
|
1097
1128
|
// runnable (or genuine) binary, so verify the real CLI exists BEFORE
|
|
@@ -1159,9 +1190,23 @@ class Installer {
|
|
|
1159
1190
|
await this.installNodejs(onData);
|
|
1160
1191
|
}
|
|
1161
1192
|
|
|
1162
|
-
|
|
1193
|
+
let env = this._buildShellEnv();
|
|
1163
1194
|
const isWin = process.platform === 'win32';
|
|
1164
1195
|
|
|
1196
|
+
// Hermes's registry command names the inbox Windows PowerShell host for
|
|
1197
|
+
// compatibility, but some Windows environments expose only PowerShell 7.
|
|
1198
|
+
// Resolve the host that actually exists and keep module paths from the
|
|
1199
|
+
// launcher's own PowerShell runtime out of both installer processes.
|
|
1200
|
+
if (agentType === 'hermes' && isWin) {
|
|
1201
|
+
const prepared = this._prepareWindowsPowerShellInstall(rawCmd, env);
|
|
1202
|
+
rawCmd = prepared.command;
|
|
1203
|
+
env = prepared.env;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Hand the installer a uv it can use, so its own fragile bootstrap is
|
|
1207
|
+
// never reached. No-op for every agent but hermes on Windows.
|
|
1208
|
+
await this._bootstrapManagedUv(agentType, env, onData);
|
|
1209
|
+
|
|
1165
1210
|
// Build the spawn invocation. Three shapes:
|
|
1166
1211
|
// 1. npm install via bundled `node npm-cli.js` — argv array, no shell.
|
|
1167
1212
|
// Path-safe under non-ASCII home dirs on Windows (preferred).
|
|
@@ -1297,15 +1342,27 @@ class Installer {
|
|
|
1297
1342
|
// The watchdog already rejected; this close is the kill it performed.
|
|
1298
1343
|
if (stalled) return;
|
|
1299
1344
|
if (code === 0) {
|
|
1345
|
+
// An installer that exits 0 without leaving a runnable binary is
|
|
1346
|
+
// the class below (aider/amp/hermes/cursor). The "…could not be
|
|
1347
|
+
// found" sentence alone says nothing about WHY, and the reason is
|
|
1348
|
+
// sitting in the output we just captured — hermes's install.ps1, for
|
|
1349
|
+
// one, prints "[X] Installation failed: uv installation failed" and
|
|
1350
|
+
// exits 0, and without the tail the launcher could only shrug
|
|
1351
|
+
// ("The installer stopped before it could finish"). Carry the tail
|
|
1352
|
+
// into the rejection so the message the user sees, and the copy that
|
|
1353
|
+
// classifies it, both have the real error in them.
|
|
1354
|
+
const failVerify = (message) => {
|
|
1355
|
+
const tail = outputTail.trim();
|
|
1356
|
+
if (onData) onData(`\n${message}\n`);
|
|
1357
|
+
reject(new Error(tail ? `${message}\n\nInstaller output:\n${tail}` : message));
|
|
1358
|
+
};
|
|
1300
1359
|
// Aider-only: confirm a real, genuine binary exists before recording
|
|
1301
1360
|
// the install (verify-before-mark; never writes a marker on
|
|
1302
1361
|
// failure). Other agent types are unaffected.
|
|
1303
1362
|
if (agentType === 'aider') {
|
|
1304
1363
|
const aider = this._verifyAiderBinary();
|
|
1305
1364
|
if (!aider) {
|
|
1306
|
-
|
|
1307
|
-
if (onData) onData(`\n${msg}\n`);
|
|
1308
|
-
reject(new Error(msg));
|
|
1365
|
+
failVerify(this._aiderBinaryNotFoundMessage());
|
|
1309
1366
|
return;
|
|
1310
1367
|
}
|
|
1311
1368
|
if (onData) onData(`\nAider CLI resolved: ${aider.path}${aider.version ? ` (${aider.version})` : ''}\n`);
|
|
@@ -1315,9 +1372,7 @@ class Installer {
|
|
|
1315
1372
|
if (agentType === 'amp') {
|
|
1316
1373
|
const amp = this._verifyAmpBinary();
|
|
1317
1374
|
if (!amp) {
|
|
1318
|
-
|
|
1319
|
-
if (onData) onData(`\n${msg}\n`);
|
|
1320
|
-
reject(new Error(msg));
|
|
1375
|
+
failVerify(this._ampBinaryNotFoundMessage());
|
|
1321
1376
|
return;
|
|
1322
1377
|
}
|
|
1323
1378
|
if (onData) onData(`\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}\n`);
|
|
@@ -1327,9 +1382,7 @@ class Installer {
|
|
|
1327
1382
|
if (agentType === 'hermes') {
|
|
1328
1383
|
const hermes = this._verifyHermesBinary();
|
|
1329
1384
|
if (!hermes) {
|
|
1330
|
-
|
|
1331
|
-
if (onData) onData(`\n${msg}\n`);
|
|
1332
|
-
reject(new Error(msg));
|
|
1385
|
+
failVerify(this._hermesBinaryNotFoundMessage());
|
|
1333
1386
|
return;
|
|
1334
1387
|
}
|
|
1335
1388
|
if (onData) onData(`\nHermes CLI resolved: ${hermes.path}\n`);
|
|
@@ -1346,9 +1399,7 @@ class Installer {
|
|
|
1346
1399
|
if (agentType === 'cursor') {
|
|
1347
1400
|
const cursor = this._verifyCursorBinary();
|
|
1348
1401
|
if (!cursor) {
|
|
1349
|
-
|
|
1350
|
-
if (onData) onData(`\n${msg}\n`);
|
|
1351
|
-
reject(new Error(msg));
|
|
1402
|
+
failVerify(this._cursorBinaryNotFoundMessage());
|
|
1352
1403
|
return;
|
|
1353
1404
|
}
|
|
1354
1405
|
if (onData) onData(`\nCursor CLI resolved: ${cursor.path}\n`);
|
|
@@ -1683,29 +1734,7 @@ class Installer {
|
|
|
1683
1734
|
const pkgName = npmPkg || npmPkgFromCmd;
|
|
1684
1735
|
if (!pkgName) return null; // script-installed agent: nothing of ours to prefer
|
|
1685
1736
|
|
|
1686
|
-
|
|
1687
|
-
path.join(getRuntimePrefix(agentType), 'node_modules'),
|
|
1688
|
-
path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
|
|
1689
|
-
];
|
|
1690
|
-
const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
|
|
1691
|
-
for (const modules of prefixes) {
|
|
1692
|
-
try {
|
|
1693
|
-
if (!fs.existsSync(path.join(modules, pkgName, 'package.json'))) continue;
|
|
1694
|
-
} catch { continue; }
|
|
1695
|
-
for (const name of [binary, ...aliases]) {
|
|
1696
|
-
for (const ext of exts) {
|
|
1697
|
-
const candidate = path.join(modules, '.bin', `${name}${ext}`);
|
|
1698
|
-
try {
|
|
1699
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
1700
|
-
} catch {}
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
// npm does not always link the root package's own bin — same case
|
|
1704
|
-
// _resolvePackageBin exists for.
|
|
1705
|
-
const own = this._resolvePackageBin(agentType, entry, binary);
|
|
1706
|
-
if (own) return own;
|
|
1707
|
-
}
|
|
1708
|
-
return null;
|
|
1737
|
+
return resolveManagedNpmBinary(agentType, pkgName, [binary, ...aliases]);
|
|
1709
1738
|
}
|
|
1710
1739
|
|
|
1711
1740
|
/**
|
|
@@ -1723,25 +1752,7 @@ class Installer {
|
|
|
1723
1752
|
if (m) npmPkgFromCmd = m[1];
|
|
1724
1753
|
}
|
|
1725
1754
|
const pkgName = npmPkg || npmPkgFromCmd || binary;
|
|
1726
|
-
|
|
1727
|
-
path.join(getRuntimePrefix(agentType), 'node_modules'),
|
|
1728
|
-
path.join(os.homedir(), '.openagents', 'nodejs', 'node_modules'),
|
|
1729
|
-
];
|
|
1730
|
-
for (const modules of prefixes) {
|
|
1731
|
-
const pkgDir = path.join(modules, pkgName);
|
|
1732
|
-
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
1733
|
-
try {
|
|
1734
|
-
if (!fs.existsSync(pkgJsonPath)) continue;
|
|
1735
|
-
const bin = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).bin;
|
|
1736
|
-
let rel = null;
|
|
1737
|
-
if (typeof bin === 'string') rel = bin;
|
|
1738
|
-
else if (bin && typeof bin === 'object') rel = bin[binary] || bin[pkgName] || Object.values(bin)[0];
|
|
1739
|
-
if (!rel) continue;
|
|
1740
|
-
const abs = path.join(pkgDir, rel);
|
|
1741
|
-
if (fs.existsSync(abs)) return abs;
|
|
1742
|
-
} catch {}
|
|
1743
|
-
}
|
|
1744
|
-
return null;
|
|
1755
|
+
return resolveManagedNpmPackageBin(agentType, pkgName, binary);
|
|
1745
1756
|
}
|
|
1746
1757
|
|
|
1747
1758
|
// -- Markers --
|
|
@@ -1907,6 +1918,168 @@ class Installer {
|
|
|
1907
1918
|
return env;
|
|
1908
1919
|
}
|
|
1909
1920
|
|
|
1921
|
+
/**
|
|
1922
|
+
* Build an isolated environment for a PowerShell child process.
|
|
1923
|
+
*
|
|
1924
|
+
* PowerShell constructs PSModulePath for the host that is starting. Passing
|
|
1925
|
+
* through a value created by another host (for example pwsh -> powershell)
|
|
1926
|
+
* can make it load binary modules from the wrong runtime. Remove every
|
|
1927
|
+
* casing of the key and let the child rebuild its own defaults.
|
|
1928
|
+
*/
|
|
1929
|
+
_powerShellChildEnv(env) {
|
|
1930
|
+
const childEnv = { ...env };
|
|
1931
|
+
for (const key of Object.keys(childEnv)) {
|
|
1932
|
+
if (key.toLowerCase() === 'psmodulepath') delete childEnv[key];
|
|
1933
|
+
}
|
|
1934
|
+
return childEnv;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Resolve a PowerShell host without assuming Windows PowerShell 5.1 exists.
|
|
1939
|
+
* Prefer the Windows inbox host for compatibility with existing installer
|
|
1940
|
+
* scripts, then fall back to pwsh from PATH or its standard install folder.
|
|
1941
|
+
*
|
|
1942
|
+
* `platform` and `exists` are seams so the Windows rules run on every CI
|
|
1943
|
+
* platform without touching the real machine.
|
|
1944
|
+
*/
|
|
1945
|
+
_resolveWindowsPowerShellHost(
|
|
1946
|
+
env,
|
|
1947
|
+
platform = process.platform,
|
|
1948
|
+
exists = fs.existsSync,
|
|
1949
|
+
) {
|
|
1950
|
+
if (platform !== 'win32') return null;
|
|
1951
|
+
const value = (name) => {
|
|
1952
|
+
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
1953
|
+
return key ? env[key] : '';
|
|
1954
|
+
};
|
|
1955
|
+
const systemRoot = value('SystemRoot') || 'C:\\Windows';
|
|
1956
|
+
const windowsPowerShell = path.win32.join(
|
|
1957
|
+
systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe',
|
|
1958
|
+
);
|
|
1959
|
+
if (exists(windowsPowerShell)) return windowsPowerShell;
|
|
1960
|
+
|
|
1961
|
+
for (const rawDir of (value('Path') || '').split(';')) {
|
|
1962
|
+
const dir = rawDir.trim().replace(/^"|"$/g, '');
|
|
1963
|
+
if (!dir) continue;
|
|
1964
|
+
const candidate = path.win32.join(dir, 'pwsh.exe');
|
|
1965
|
+
if (exists(candidate)) return candidate;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const programFiles = value('ProgramFiles') || 'C:\\Program Files';
|
|
1969
|
+
const standardPwsh = path.win32.join(programFiles, 'PowerShell', '7', 'pwsh.exe');
|
|
1970
|
+
return exists(standardPwsh) ? standardPwsh : null;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/** Replace only the leading PowerShell executable in an install command. */
|
|
1974
|
+
_retargetPowerShellCommand(command, executable) {
|
|
1975
|
+
const hostPrefix = /^(\s*)(?:"[^"]*\\(?:powershell|pwsh)(?:\.exe)?"|(?:powershell|pwsh)(?:\.exe)?)\s+/i;
|
|
1976
|
+
return command.replace(hostPrefix, `$1"${executable}" `);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/** Prepare a Windows PowerShell install without leaking another host's modules. */
|
|
1980
|
+
_prepareWindowsPowerShellInstall(
|
|
1981
|
+
command,
|
|
1982
|
+
env,
|
|
1983
|
+
platform = process.platform,
|
|
1984
|
+
exists = fs.existsSync,
|
|
1985
|
+
) {
|
|
1986
|
+
const host = this._resolveWindowsPowerShellHost(env, platform, exists);
|
|
1987
|
+
if (!host) {
|
|
1988
|
+
throw new Error('Windows PowerShell or PowerShell 7 is required for this installer.');
|
|
1989
|
+
}
|
|
1990
|
+
return {
|
|
1991
|
+
command: this._retargetPowerShellCommand(command, host),
|
|
1992
|
+
env: this._powerShellChildEnv(env),
|
|
1993
|
+
host,
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Put uv where hermes's installer looks for it, before that installer runs.
|
|
1999
|
+
*
|
|
2000
|
+
* hermes's Install-Uv bootstraps its OWN copy at <HERMES_HOME>\bin\uv.exe
|
|
2001
|
+
* ("no PATH probing, no conda guards" — a uv already on the machine is not
|
|
2002
|
+
* used), and it does that by spawning a child PowerShell for the astral
|
|
2003
|
+
* installer WITHOUT -NoProfile. When that child cannot run — a profile that
|
|
2004
|
+
* throws, a PSModulePath that cannot load built-in modules — uv never lands,
|
|
2005
|
+
* and hermes prints "Installation failed: uv installation failed" and exits
|
|
2006
|
+
* 0 anyway, leaving a half-installed agent.
|
|
2007
|
+
*
|
|
2008
|
+
* Doing the same download ourselves, from an environment we control, takes
|
|
2009
|
+
* that fragile hop out of the install. Its early
|
|
2010
|
+
* `if (Test-Path $managedUv) { return $true }` then short-circuits the whole
|
|
2011
|
+
* step. Best-effort by design: any failure here is logged and the install
|
|
2012
|
+
* proceeds exactly as it does today, so this can only add successes.
|
|
2013
|
+
*
|
|
2014
|
+
* Windows-only. The Unix install.sh runs the uv installer through `sh`,
|
|
2015
|
+
* which reads no profile and has never shown this failure.
|
|
2016
|
+
*
|
|
2017
|
+
* `platform` and `exists` are seams for tests; production passes neither.
|
|
2018
|
+
* They must be threaded into the host lookup below — without them it falls
|
|
2019
|
+
* back to the real filesystem and can never resolve a Windows path on a
|
|
2020
|
+
* Linux or macOS CI runner, which silently turned every assertion about the
|
|
2021
|
+
* spawn into a no-op.
|
|
2022
|
+
*/
|
|
2023
|
+
async _bootstrapManagedUv(
|
|
2024
|
+
agentType,
|
|
2025
|
+
env,
|
|
2026
|
+
onData,
|
|
2027
|
+
platform = process.platform,
|
|
2028
|
+
exists = fs.existsSync,
|
|
2029
|
+
) {
|
|
2030
|
+
if (agentType !== 'hermes' || platform !== 'win32') return;
|
|
2031
|
+
const home = os.homedir();
|
|
2032
|
+
const hermesHome = env.HERMES_HOME
|
|
2033
|
+
|| path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'hermes');
|
|
2034
|
+
const binDir = path.join(hermesHome, 'bin');
|
|
2035
|
+
const managedUv = path.join(binDir, 'uv.exe');
|
|
2036
|
+
try { if (exists(managedUv)) return; } catch { return; }
|
|
2037
|
+
|
|
2038
|
+
if (onData) onData(`\nProvisioning uv for Hermes into ${binDir} ...\n`);
|
|
2039
|
+
try {
|
|
2040
|
+
const psExe = this._resolveWindowsPowerShellHost(env, platform, exists);
|
|
2041
|
+
if (!psExe) throw new Error('no Windows PowerShell or PowerShell 7 host found');
|
|
2042
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
2043
|
+
await new Promise((resolve, reject) => {
|
|
2044
|
+
// `_spawnForTest` is a seam so a test can inspect the environment this
|
|
2045
|
+
// child is given; production never sets it.
|
|
2046
|
+
const spawnFn = this._spawnForTest || require('child_process').spawn;
|
|
2047
|
+
const child = spawnFn(
|
|
2048
|
+
psExe,
|
|
2049
|
+
[
|
|
2050
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
2051
|
+
// Force UTF-8 out of the child before it prints anything. A
|
|
2052
|
+
// Chinese-locale Windows emits GBK, which reaches the install log
|
|
2053
|
+
// as mojibake — the user saw a wall of "??" where the error was.
|
|
2054
|
+
'-Command',
|
|
2055
|
+
'[Console]::OutputEncoding=[Text.Encoding]::UTF8; irm https://astral.sh/uv/install.ps1 | iex',
|
|
2056
|
+
],
|
|
2057
|
+
{
|
|
2058
|
+
// UV_INSTALL_DIR is what the astral script honours; it is the same
|
|
2059
|
+
// variable hermes sets for its own attempt.
|
|
2060
|
+
env: this._powerShellChildEnv({ ...env, UV_INSTALL_DIR: binDir }),
|
|
2061
|
+
cwd: home,
|
|
2062
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2063
|
+
windowsHide: true,
|
|
2064
|
+
},
|
|
2065
|
+
);
|
|
2066
|
+
const relay = (d) => { if (onData) onData(String(d)); };
|
|
2067
|
+
if (child.stdout) child.stdout.on('data', relay);
|
|
2068
|
+
if (child.stderr) child.stderr.on('data', relay);
|
|
2069
|
+
child.on('error', reject);
|
|
2070
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit code ${code}`))));
|
|
2071
|
+
});
|
|
2072
|
+
} catch (e) {
|
|
2073
|
+
if (onData) onData(`\nCould not provision uv ahead of the installer (${e.message}); letting Hermes try its own.\n`);
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (onData) {
|
|
2077
|
+
onData(fs.existsSync(managedUv)
|
|
2078
|
+
? `uv ready at ${managedUv}\n`
|
|
2079
|
+
: `uv installer finished but ${managedUv} is absent; letting Hermes try its own.\n`);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
1910
2083
|
/**
|
|
1911
2084
|
* True if `node` exists outside the bundled launcher directory. Uses raw
|
|
1912
2085
|
* process.env.PATH (no enhancement) so we don't accidentally detect the
|
|
@@ -2276,9 +2449,9 @@ class Installer {
|
|
|
2276
2449
|
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${escaped}"`;
|
|
2277
2450
|
}
|
|
2278
2451
|
|
|
2279
|
-
_execShell(cmd, timeoutMs = 300000) {
|
|
2452
|
+
_execShell(cmd, timeoutMs = 300000, envOverride = null) {
|
|
2280
2453
|
return new Promise((resolve, reject) => {
|
|
2281
|
-
const env = this._buildShellEnv();
|
|
2454
|
+
const env = envOverride || this._buildShellEnv();
|
|
2282
2455
|
|
|
2283
2456
|
let shell = true;
|
|
2284
2457
|
if (process.platform === 'win32') {
|