baychat 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -1
- package/dist/args.js +57 -0
- package/dist/client-paths.js +69 -0
- package/dist/commands.js +108 -0
- package/dist/connect.js +46 -14
- package/dist/credential-refresh.js +97 -0
- package/dist/doctor-command.js +154 -0
- package/dist/doctor.js +502 -0
- package/dist/index.js +40 -26
- package/dist/relay/adapters.js +82 -1
- package/dist/relay/codex-app-server.js +217 -0
- package/dist/relay/codex-queue.js +68 -0
- package/dist/relay/commands.js +177 -31
- package/dist/relay/daemon.js +259 -3
- package/dist/relay/mailbox-watcher.js +118 -0
- package/dist/relay/mailbox.js +319 -0
- package/dist/relay/parent-watch.js +68 -0
- package/dist/relay/resume.js +39 -11
- package/dist/relay/socket.js +98 -14
- package/dist/relay/spawn-env.js +69 -0
- package/dist/runtime-binary.js +269 -0
- package/dist/runtimes.js +118 -46
- package/package.json +2 -2
package/dist/relay/socket.js
CHANGED
|
@@ -34,11 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.socketPath = socketPath;
|
|
37
|
+
exports.unconfineRuntimeDir = unconfineRuntimeDir;
|
|
37
38
|
exports.isNamedPipe = isNamedPipe;
|
|
38
39
|
exports.pidFilePath = pidFilePath;
|
|
39
40
|
exports.createFrameReader = createFrameReader;
|
|
40
41
|
exports.writeFrame = writeFrame;
|
|
42
|
+
exports.probeSocketDetailed = probeSocketDetailed;
|
|
41
43
|
exports.probeSocket = probeSocket;
|
|
44
|
+
exports.describeProbeFailure = describeProbeFailure;
|
|
42
45
|
exports.unlinkStaleSocket = unlinkStaleSocket;
|
|
43
46
|
const fs = __importStar(require("fs"));
|
|
44
47
|
const net = __importStar(require("net"));
|
|
@@ -76,9 +79,34 @@ function socketPath() {
|
|
|
76
79
|
}
|
|
77
80
|
const runtimeDir = process.env.XDG_RUNTIME_DIR;
|
|
78
81
|
if (runtimeDir)
|
|
79
|
-
return path.join(runtimeDir, "baychat-relay.sock");
|
|
82
|
+
return path.join(unconfineRuntimeDir(runtimeDir), "baychat-relay.sock");
|
|
80
83
|
return path.join((0, config_1.configDir)(), "relay.sock");
|
|
81
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* The runtime dir the RELAY uses, as seen from inside a snap.
|
|
87
|
+
*
|
|
88
|
+
* A confined snap does not get the user's `XDG_RUNTIME_DIR`: snapd rewrites it
|
|
89
|
+
* to a private subdirectory, `/run/user/1000/snap.codex`. A snap-installed Codex
|
|
90
|
+
* therefore computes a socket path no relay has ever listened on, finds nothing,
|
|
91
|
+
* and reports "no relay is running" — while the real socket sits one level up,
|
|
92
|
+
* readable and connectable by the same uid. Verified on 2026-08-30: from inside
|
|
93
|
+
* `snap run --shell codex`, `/run/user/1000/baychat-relay.sock` connects fine;
|
|
94
|
+
* only the path was wrong.
|
|
95
|
+
*
|
|
96
|
+
* This is RECURRING_MISTAKES §24 again — "where it keeps its state" is not
|
|
97
|
+
* "where this copy reads" — and the same shape as `codexRoots`, which already
|
|
98
|
+
* has to know that a snap Codex keeps its rollouts somewhere else.
|
|
99
|
+
*
|
|
100
|
+
* Guarded on `SNAP` so an ordinary process is never second-guessed, and it only
|
|
101
|
+
* strips a segment that actually looks like snapd's, so a user whose runtime dir
|
|
102
|
+
* legitimately ends in something else is left alone.
|
|
103
|
+
*/
|
|
104
|
+
function unconfineRuntimeDir(runtimeDir, env = process.env) {
|
|
105
|
+
if (!env.SNAP)
|
|
106
|
+
return runtimeDir;
|
|
107
|
+
const parent = path.dirname(runtimeDir);
|
|
108
|
+
return /^snap\.[^/\\]+$/.test(path.basename(runtimeDir)) ? parent : runtimeDir;
|
|
109
|
+
}
|
|
82
110
|
/** A pipe name may not contain a backslash — it would read as another level of
|
|
83
111
|
* the pipe namespace — and Windows usernames legally can (`DOMAIN\user` reaches
|
|
84
112
|
* `os.userInfo()` that way on some setups). Everything outside the safe set is
|
|
@@ -129,31 +157,87 @@ function writeFrame(sock, frame) {
|
|
|
129
157
|
sock.write(JSON.stringify(frame) + "\n");
|
|
130
158
|
}
|
|
131
159
|
/**
|
|
132
|
-
*
|
|
160
|
+
* Probe the attach endpoint, KEEPING THE REASON IT FAILED.
|
|
161
|
+
*
|
|
162
|
+
* The boolean version of this threw the errno away, and every distinct failure
|
|
163
|
+
* arrived at the user as one sentence: "no relay is running". That sentence is
|
|
164
|
+
* true for exactly one of them. A sandboxed or differently-elevated shell that
|
|
165
|
+
* is REFUSED the pipe (EACCES/EPERM) is told the daemon does not exist, so the
|
|
166
|
+
* obvious next move is `relay start` — which spawns a SECOND daemon, and two
|
|
167
|
+
* daemons on one credential is a materially worse state than the one we were
|
|
168
|
+
* diagnosing. A wedged daemon that never accepts is likewise reported as absent.
|
|
133
169
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* a human deleting files.
|
|
170
|
+
* Measured on 2026-08-30: a Codex session on Windows reported "no relay is
|
|
171
|
+
* running" while a healthy relay (pid 16388) held four of its messages, and the
|
|
172
|
+
* CLI could not say which of these it had hit. Hence the reason code.
|
|
138
173
|
*/
|
|
139
|
-
function
|
|
174
|
+
function probeSocketDetailed(sockPath, timeoutMs = 1_000) {
|
|
140
175
|
return new Promise((resolve) => {
|
|
141
176
|
// The existence check is a cheap way to skip a connect that cannot succeed —
|
|
142
177
|
// but a named pipe has no directory entry, so on Windows it answers "false"
|
|
143
178
|
// for a perfectly healthy daemon. Skipped there; the connect below is the
|
|
144
179
|
// honest test on every platform anyway.
|
|
145
|
-
if (!isNamedPipe(sockPath)
|
|
146
|
-
|
|
180
|
+
if (!isNamedPipe(sockPath)) {
|
|
181
|
+
// `fs.existsSync` cannot be used here: it answers false for BOTH "there is
|
|
182
|
+
// nothing at this path" and "you may not look at this path", which is the
|
|
183
|
+
// very conflation this function exists to end. `statSync` throws an errno
|
|
184
|
+
// that separates them.
|
|
185
|
+
try {
|
|
186
|
+
fs.statSync(sockPath);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
const code = err.code;
|
|
190
|
+
if (code === "ENOENT")
|
|
191
|
+
return resolve({ alive: false, reason: "absent" });
|
|
192
|
+
const reason = code === "EACCES" || code === "EPERM" ? "denied" : "error";
|
|
193
|
+
return resolve({ alive: false, reason, code, detail: err.message });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
147
196
|
const sock = net.createConnection(sockPath);
|
|
148
|
-
const done = (
|
|
197
|
+
const done = (result) => {
|
|
149
198
|
sock.destroy();
|
|
150
|
-
resolve(
|
|
199
|
+
resolve(result);
|
|
151
200
|
};
|
|
152
|
-
sock.setTimeout(timeoutMs, () => done(false));
|
|
153
|
-
sock.on("connect", () => done(true));
|
|
154
|
-
sock.on("error", () =>
|
|
201
|
+
sock.setTimeout(timeoutMs, () => done({ alive: false, reason: "timeout" }));
|
|
202
|
+
sock.on("connect", () => done({ alive: true }));
|
|
203
|
+
sock.on("error", (err) => {
|
|
204
|
+
const code = err.code;
|
|
205
|
+
// ENOENT/ECONNREFUSED genuinely mean nothing is listening. EACCES/EPERM
|
|
206
|
+
// mean something IS there and we were refused — the opposite diagnosis.
|
|
207
|
+
const reason = code === "ENOENT" || code === "ECONNREFUSED" ? "absent" : code === "EACCES" || code === "EPERM" ? "denied" : "error";
|
|
208
|
+
done({ alive: false, reason, code, detail: err.message });
|
|
209
|
+
});
|
|
155
210
|
});
|
|
156
211
|
}
|
|
212
|
+
function probeSocket(sockPath, timeoutMs = 1_000) {
|
|
213
|
+
return probeSocketDetailed(sockPath, timeoutMs).then((r) => r.alive);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Turn a failed probe into a sentence that names the ACTUAL obstacle.
|
|
217
|
+
*
|
|
218
|
+
* Only `absent` may suggest `relay start`; the others must not, because
|
|
219
|
+
* starting a second daemon is the wrong move for all of them.
|
|
220
|
+
*/
|
|
221
|
+
function describeProbeFailure(sockPath, result) {
|
|
222
|
+
if (result.alive)
|
|
223
|
+
return "";
|
|
224
|
+
switch (result.reason) {
|
|
225
|
+
case "absent":
|
|
226
|
+
return "no relay is running — start one with `baychat relay start`";
|
|
227
|
+
case "denied":
|
|
228
|
+
return (`a relay endpoint exists at ${sockPath} but this process was refused it (${result.code}). ` +
|
|
229
|
+
`That is a permission boundary, not a missing daemon — do NOT run \`relay start\`, which would ` +
|
|
230
|
+
`create a second one. It usually means this shell runs under a different token than the relay ` +
|
|
231
|
+
`(an elevated or sandboxed shell), so run from a normal shell as the same user, or restart the ` +
|
|
232
|
+
`relay from this shell.`);
|
|
233
|
+
case "timeout":
|
|
234
|
+
return (`a relay is listening at ${sockPath} but did not accept a connection within ${1_000}ms — it is ` +
|
|
235
|
+
`probably wedged rather than absent. Check \`relay status\` from the shell that started it, and ` +
|
|
236
|
+
`stop that daemon before starting another.`);
|
|
237
|
+
default:
|
|
238
|
+
return `could not reach the relay at ${sockPath}: ${result.code ?? "unknown error"}${result.detail ? ` (${result.detail})` : ""}`;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
157
241
|
/** Remove a socket file we have already proven dead.
|
|
158
242
|
*
|
|
159
243
|
* A no-op for a named pipe, which has nothing on disk to remove: Windows tears
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.headlessSpawnEnv = headlessSpawnEnv;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
/**
|
|
39
|
+
* The environment a headless turn is spawned with.
|
|
40
|
+
*
|
|
41
|
+
* WHY THIS EXISTS. A runtime installed by npm is a JavaScript file with a
|
|
42
|
+
* `#!/usr/bin/env node` shebang, so running it asks the SPAWNING process's PATH
|
|
43
|
+
* to find `node`. The relay daemon runs under systemd, whose PATH is a minimal
|
|
44
|
+
* system one — it does not contain nvm's node, or any node at all on a machine
|
|
45
|
+
* where node was never installed system-wide.
|
|
46
|
+
*
|
|
47
|
+
* Measured 2026-08-31 00:06: the headless rung became reachable for the first
|
|
48
|
+
* time and every wake died instantly with
|
|
49
|
+
* `/usr/bin/env: 'node': No such file or directory`, exit 127. The binary was
|
|
50
|
+
* correct and present; nothing could run it.
|
|
51
|
+
*
|
|
52
|
+
* This is `SessionTarget.runtimeBin` one layer down. That rule says the session
|
|
53
|
+
* identifies its own binary because only it can; this says the daemon must also
|
|
54
|
+
* hand that binary an environment it can actually start in — and the daemon is
|
|
55
|
+
* the only process that knows where its own node lives (`process.execPath`,
|
|
56
|
+
* which is exactly the interpreter a shebang is looking for).
|
|
57
|
+
*
|
|
58
|
+
* PREPENDED, not replaced: a runtime may legitimately need the rest of the
|
|
59
|
+
* inherited PATH to find its own helpers, and clobbering it would trade this
|
|
60
|
+
* failure for a subtler one.
|
|
61
|
+
*/
|
|
62
|
+
function headlessSpawnEnv(env = process.env, execPath = process.execPath) {
|
|
63
|
+
const nodeDir = path.dirname(execPath);
|
|
64
|
+
const current = env.PATH ?? "";
|
|
65
|
+
const parts = current.split(path.delimiter).filter(Boolean);
|
|
66
|
+
if (parts.includes(nodeDir))
|
|
67
|
+
return env;
|
|
68
|
+
return { ...env, PATH: [nodeDir, ...parts].join(path.delimiter) };
|
|
69
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveRuntimeBinary = resolveRuntimeBinary;
|
|
37
|
+
exports.describeResolution = describeResolution;
|
|
38
|
+
exports.spawnPlanFor = spawnPlanFor;
|
|
39
|
+
exports.summarizeProbe = summarizeProbe;
|
|
40
|
+
exports.currentBinaryEnv = currentBinaryEnv;
|
|
41
|
+
exports.summarizeResolutionFailure = summarizeResolutionFailure;
|
|
42
|
+
const child_process_1 = require("child_process");
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a runtime's executable, proving each candidate before accepting it.
|
|
46
|
+
*
|
|
47
|
+
* Precedence: explicit override (exclusively), then native PATH entries in
|
|
48
|
+
* order, then foreign-filesystem PATH entries. Foreign entries are demoted
|
|
49
|
+
* rather than excluded — a user genuinely running the Windows build through WSL
|
|
50
|
+
* interop must still be able to work — but they never beat a native binary that
|
|
51
|
+
* runs, because spawning across the interop boundary means a different
|
|
52
|
+
* filesystem and a different home than the session it is answering for.
|
|
53
|
+
*/
|
|
54
|
+
function resolveRuntimeBinary(name, env) {
|
|
55
|
+
if (env.override !== undefined && env.override.trim() !== "") {
|
|
56
|
+
return resolveOverride(env.override, env);
|
|
57
|
+
}
|
|
58
|
+
const candidates = pathCandidates(name, env);
|
|
59
|
+
if (candidates.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
reason: `${name} not found on PATH (${env.pathEntries.length} entries searched)`,
|
|
62
|
+
rejected: [],
|
|
63
|
+
ok: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const rejected = [];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
const probed = env.probe(candidate.path);
|
|
69
|
+
if (probed.ok) {
|
|
70
|
+
return { ok: true, path: candidate.path, version: probed.version, source: candidate.source };
|
|
71
|
+
}
|
|
72
|
+
rejected.push({ path: candidate.path, reason: probed.detail });
|
|
73
|
+
}
|
|
74
|
+
return { ok: false, reason: `no working ${name} binary on this machine`, rejected };
|
|
75
|
+
}
|
|
76
|
+
/** Render a resolution for a human — one line on success, a full account on failure. */
|
|
77
|
+
function describeResolution(name, resolution) {
|
|
78
|
+
if (resolution.ok)
|
|
79
|
+
return [`${resolution.path} (${resolution.version})`];
|
|
80
|
+
const lines = [resolution.reason];
|
|
81
|
+
for (const candidate of resolution.rejected) {
|
|
82
|
+
lines.push(` tried ${candidate.path}`);
|
|
83
|
+
lines.push(` → ${candidate.reason}`);
|
|
84
|
+
}
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* An override is considered alone, and its failure is the answer.
|
|
89
|
+
*
|
|
90
|
+
* Falling through to PATH would mean the CLI used a binary the user did not name
|
|
91
|
+
* while their explicit setting sat broken and unreported.
|
|
92
|
+
*/
|
|
93
|
+
function resolveOverride(override, env) {
|
|
94
|
+
if (!env.isExecutable(override)) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: `configured override ${override} is not an executable file`,
|
|
98
|
+
rejected: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const probed = env.probe(override);
|
|
102
|
+
if (probed.ok)
|
|
103
|
+
return { ok: true, path: override, version: probed.version, source: "override" };
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
reason: `configured override ${override} does not run`,
|
|
107
|
+
rejected: [{ path: override, reason: probed.detail }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Every executable candidate on PATH, native entries first.
|
|
112
|
+
*
|
|
113
|
+
* Deduplicated by path: WSL routinely lists the same Windows npm directory
|
|
114
|
+
* twice, and probing spawns a process, so a duplicate entry would double the
|
|
115
|
+
* cost of resolution for no new information.
|
|
116
|
+
*/
|
|
117
|
+
function pathCandidates(name, env) {
|
|
118
|
+
const native = [];
|
|
119
|
+
const foreign = [];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
for (const entry of env.pathEntries) {
|
|
122
|
+
if (entry.trim() === "")
|
|
123
|
+
continue;
|
|
124
|
+
const source = isForeignMount(entry, env.platform) ? "foreign-path" : "path";
|
|
125
|
+
for (const fileName of executableNames(name, env.platform)) {
|
|
126
|
+
const candidate = joinPath(entry, fileName, env.platform);
|
|
127
|
+
if (seen.has(candidate))
|
|
128
|
+
continue;
|
|
129
|
+
seen.add(candidate);
|
|
130
|
+
if (!env.isExecutable(candidate))
|
|
131
|
+
continue;
|
|
132
|
+
(source === "path" ? native : foreign).push({ path: candidate, source });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [...native, ...foreign];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Whether a PATH entry lives on a Windows drive mounted into Linux.
|
|
139
|
+
*
|
|
140
|
+
* Deliberately narrow: only `/mnt/<single letter>/…` counts. `/mnt/data` is an
|
|
141
|
+
* ordinary Linux mount and demoting it would be wrong.
|
|
142
|
+
*/
|
|
143
|
+
function isForeignMount(entry, platform) {
|
|
144
|
+
if (platform !== "linux")
|
|
145
|
+
return false;
|
|
146
|
+
return /^\/mnt\/[a-z]\//i.test(entry);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The file names a runtime may have on this platform.
|
|
150
|
+
*
|
|
151
|
+
* Windows resolves an unqualified command against PATHEXT; an npm-installed CLI
|
|
152
|
+
* is typically a `.cmd` shim, so omitting the extensions would find nothing at
|
|
153
|
+
* all there.
|
|
154
|
+
*/
|
|
155
|
+
function executableNames(name, platform) {
|
|
156
|
+
if (platform !== "win32")
|
|
157
|
+
return [name];
|
|
158
|
+
return [`${name}.exe`, `${name}.cmd`, `${name}.bat`, name];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Join a directory and a file name for the TARGET platform.
|
|
162
|
+
*
|
|
163
|
+
* Node's `path.join` follows the host, not the injected platform, so using it
|
|
164
|
+
* would make every Windows case untestable from Linux CI.
|
|
165
|
+
*/
|
|
166
|
+
function joinPath(dir, file, platform) {
|
|
167
|
+
const separator = platform === "win32" ? "\\" : "/";
|
|
168
|
+
const trimmed = dir.endsWith(separator) ? dir.slice(0, -separator.length) : dir;
|
|
169
|
+
return `${trimmed}${separator}${file}`;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* @param candidate an executable path already resolved by `resolveRuntimeBinary`
|
|
173
|
+
* @returns file + prefix args to spawn it with, always `shell: false`
|
|
174
|
+
*/
|
|
175
|
+
function spawnPlanFor(candidate, platform) {
|
|
176
|
+
const needsInterpreter = platform === "win32" && /\.(cmd|bat)$/i.test(candidate);
|
|
177
|
+
if (!needsInterpreter)
|
|
178
|
+
return { file: candidate, prefixArgs: [] };
|
|
179
|
+
return { file: "cmd.exe", prefixArgs: ["/d", "/s", "/c", candidate] };
|
|
180
|
+
}
|
|
181
|
+
/** How long a `--version` probe may run before it is treated as broken. */
|
|
182
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
183
|
+
/** The most stderr worth quoting back to a user in a rejection reason. */
|
|
184
|
+
const PROBE_DETAIL_LIMIT = 200;
|
|
185
|
+
/**
|
|
186
|
+
* Turn a finished `--version` run into a probe result.
|
|
187
|
+
*
|
|
188
|
+
* Split out from the spawn so the interesting half — deciding what counts as
|
|
189
|
+
* working, and what to quote when it does not — is testable without a process.
|
|
190
|
+
*/
|
|
191
|
+
function summarizeProbe(outcome) {
|
|
192
|
+
if (outcome.error)
|
|
193
|
+
return { ok: false, detail: outcome.error.message };
|
|
194
|
+
if (outcome.status !== 0) {
|
|
195
|
+
// Prefer stderr: a failing CLI puts its diagnosis there, and it is what
|
|
196
|
+
// names the actual fault (e.g. the missing optional dependency).
|
|
197
|
+
const said = firstMeaningfulLine(outcome.stderr) || firstMeaningfulLine(outcome.stdout);
|
|
198
|
+
const exited = `exits ${outcome.status ?? "on a signal"}`;
|
|
199
|
+
return { ok: false, detail: said ? `${exited}: ${said}` : exited };
|
|
200
|
+
}
|
|
201
|
+
const version = firstMeaningfulLine(outcome.stdout) || firstMeaningfulLine(outcome.stderr);
|
|
202
|
+
// Exit 0 is the contract. A binary that runs but prints nothing recognisable
|
|
203
|
+
// still runs, so it is accepted with an honest placeholder rather than
|
|
204
|
+
// rejected for a cosmetic reason.
|
|
205
|
+
return { ok: true, version: version || "version not reported" };
|
|
206
|
+
}
|
|
207
|
+
function firstMeaningfulLine(text) {
|
|
208
|
+
for (const line of text.split("\n")) {
|
|
209
|
+
const trimmed = line.trim();
|
|
210
|
+
if (trimmed !== "")
|
|
211
|
+
return trimmed.slice(0, PROBE_DETAIL_LIMIT);
|
|
212
|
+
}
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Read the binary environment from this process.
|
|
217
|
+
*
|
|
218
|
+
* `shell: false` throughout: nothing here is ever concatenated into a command
|
|
219
|
+
* line, and a PATH entry is attacker-adjacent data on a shared machine.
|
|
220
|
+
*/
|
|
221
|
+
function currentBinaryEnv(override) {
|
|
222
|
+
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
223
|
+
return {
|
|
224
|
+
platform: process.platform,
|
|
225
|
+
pathEntries: (process.env.PATH ?? "").split(delimiter),
|
|
226
|
+
override,
|
|
227
|
+
isExecutable(candidate) {
|
|
228
|
+
try {
|
|
229
|
+
return fs.statSync(candidate).isFile();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Absent, unreadable, or a dangling symlink — all mean "not a candidate".
|
|
233
|
+
// There is nothing to log: most PATH entries do not contain most binaries.
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
probe(candidate) {
|
|
238
|
+
const plan = spawnPlanFor(candidate, process.platform);
|
|
239
|
+
const run = (0, child_process_1.spawnSync)(plan.file, [...plan.prefixArgs, "--version"], {
|
|
240
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
241
|
+
encoding: "utf8",
|
|
242
|
+
shell: false,
|
|
243
|
+
});
|
|
244
|
+
return summarizeProbe({
|
|
245
|
+
status: run.status,
|
|
246
|
+
stdout: run.stdout ?? "",
|
|
247
|
+
stderr: run.stderr ?? "",
|
|
248
|
+
error: run.error,
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* One line naming the failure and the best evidence for it.
|
|
255
|
+
*
|
|
256
|
+
* `describeResolution` is shaped for a report a human is reading deliberately;
|
|
257
|
+
* this is for a place that has room for a sentence — a `DELIVERY PENDING`
|
|
258
|
+
* reason in `relay status`, a log line — where a multi-line block would wrap
|
|
259
|
+
* into noise. It names the first rejected candidate because that is the one
|
|
260
|
+
* PATH would have chosen, and therefore the one the user believes is in use.
|
|
261
|
+
*/
|
|
262
|
+
function summarizeResolutionFailure(resolution) {
|
|
263
|
+
if (resolution.ok)
|
|
264
|
+
return "";
|
|
265
|
+
const first = resolution.rejected[0];
|
|
266
|
+
if (!first)
|
|
267
|
+
return resolution.reason;
|
|
268
|
+
return `${resolution.reason} (tried ${first.path}: ${first.reason})`;
|
|
269
|
+
}
|