@yawlabs/ssh-mcp 0.15.1 → 0.15.3

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.
Files changed (3) hide show
  1. package/README.md +39 -0
  2. package/bin/ssh-mcp.mjs +510 -270
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -303,10 +303,49 @@ for (const check of report.checks) {
303
303
  }
304
304
  ```
305
305
 
306
+ ## Runtime selection
307
+
308
+ The `ssh-mcp` command — what `npx @yawlabs/ssh-mcp` runs — is a small launcher. It prefers the [oam](https://oamjs.org) runtime and otherwise runs the server on Node, so oam is optional. Its minimum is the **latest oam release, currently 0.15.2**, and it never serves on an older oam.
309
+
310
+ Two environment variables control the choice; set them in your MCP client's `env` block:
311
+
312
+ - `SSH_MCP_RUNTIME` — which runtime to use. Case-insensitive; any value other than `oam` or `node` behaves as `auto`.
313
+ - `auto` (default) — use the newest oam it can find at 0.15.2 or newer, otherwise run on Node.
314
+ - `oam` — require oam. Fails loudly: if no usable oam is found, the launcher prints what it found and why each one was passed over to stderr, and exits with status 1 instead of starting the server.
315
+ - `node` — always Node. The launcher does not look for oam, and `OAM_BIN` is ignored.
316
+ - `OAM_BIN` — path to an oam binary to use in preference to discovery, when it is 0.15.2 or newer. If it does not exist, is older, or will not run, the launcher says so on stderr and carries on with discovery.
317
+
318
+ Discovery looks in `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then every directory on `PATH`; asks every oam binary it finds for its version; and uses the newest one at 0.15.2 or newer. On a tie the one found first wins, so an installed copy beats one on `PATH`. On Windows only `oam.exe` counts: an `oam.cmd`/`oam.bat` shim on `PATH` is never run, and is named on stderr when no usable oam is found.
319
+
320
+ By default an unusable oam is not an error. When Node starts the launcher (`npx` or a global install, run directly by your MCP client), `auto` mode falls back to Node — silently when there was nothing to find, or with a note on stderr naming each oam (and `OAM_BIN`) it passed over and why: older than 0.15.2, not runnable, or a shim. A chosen oam that then fails to start is named on stderr too. When an older oam starts the launcher, the handoff to Node is always noted on stderr (see below). With `SSH_MCP_RUNTIME=oam`, each of these cases exits with status 1 instead.
321
+
322
+ When the launcher is itself started by oam (`oam run <path>/bin/ssh-mcp.mjs`, which is how Yaw MCP starts an `npx @yawlabs/ssh-mcp` entry when a recent oam is installed):
323
+
324
+ - On oam 0.15.2 or newer it runs the server on that oam, in the same process. Nothing is discovered or spawned, `OAM_BIN` is not read, and `SSH_MCP_RUNTIME=oam` counts it as the oam it requires.
325
+ - On an older oam it never runs the server there. It hands the server off to the newest usable oam it can find, or to Node on `PATH`, or exits with status 1 when there is neither (`SSH_MCP_RUNTIME=oam` exits with status 1 rather than handing off to Node).
326
+ - With `SSH_MCP_RUNTIME=node` it hands the server off to Node on `PATH`, whatever the oam version.
327
+
328
+ For example, to always run on Node:
329
+
330
+ ```json
331
+ {
332
+ "mcpServers": {
333
+ "ssh": {
334
+ "command": "npx",
335
+ "args": ["-y", "@yawlabs/ssh-mcp@latest"],
336
+ "env": { "SSH_MCP_RUNTIME": "node" }
337
+ }
338
+ }
339
+ }
340
+ ```
341
+
342
+ On Windows, add the same `env` block to the `cmd /c` form from [Quick start](#quick-start).
343
+
306
344
  ## Requirements
307
345
 
308
346
  - Node.js 18+
309
347
  - SSH client installed (for diagnostics and environment management)
348
+ - Optional: [oam](https://oamjs.org) 0.15.2+ — see [Runtime selection](#runtime-selection)
310
349
 
311
350
  ## License
312
351
 
package/bin/ssh-mcp.mjs CHANGED
@@ -2,26 +2,73 @@
2
2
  /**
3
3
  * Runtime launcher for @yawlabs/ssh-mcp.
4
4
  *
5
- * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
- * process already running this file.
5
+ * Prefers the newest usable oam runtime (https://oamjs.org) and falls back to
6
+ * Node. It never serves on an oam older than the floor below.
7
7
  *
8
8
  *
9
9
  * WHY THE FALLBACK COSTS NOTHING
10
10
  * npm has already started Node to run this launcher, so falling back is a
11
11
  * plain `import()` of the server into THIS process: no extra spawn, no extra
12
- * startup, byte-identical to invoking dist/index.js directly. Discovery is
13
- * stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
12
+ * startup, byte-identical to invoking dist/index.js directly. Finding the
13
+ * candidates is stat-only, so a machine without oam never pays for a
14
+ * subprocess.
14
15
  *
15
16
  * WHAT THE OAM PATH COSTS
16
- * Reaching oam through an npm `bin` means Node boots first and oam boots
17
- * second, so the launcher is slower than either runtime alone. Measured on
17
+ * Reaching oam through an npm `bin` means Node boots first, every oam binary
18
+ * found is asked for its version, and then oam boots to serve -- so the
19
+ * launcher is slower than pointing a host at oam directly. Measured on
18
20
  * npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
19
- * oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
20
- * launcher is the slowest path -- it exists for `npx` convenience.
21
+ * oam 116ms, node 172ms, launcher 243ms. It exists for `npx` convenience.
22
+ *
23
+ * One `oam --version` probe measured 26ms median (n=12, windows-arm64). It is
24
+ * paid once per oam binary found, on every launch that runs discovery and finds
25
+ * one -- including the launches that go on to fall back to Node.
21
26
  *
22
27
  * For an MCP host config, point straight at oam and skip this file:
23
28
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
29
  *
30
+ * WHICH OAM
31
+ * OAM_BIN, when set and usable, is used as given. Otherwise every oam binary
32
+ * discovery can see -- the installed locations, then PATH -- is asked for its
33
+ * version, and the NEWEST one at or above the floor wins; a tie keeps search
34
+ * order. Taking the first binary found instead let a stale copy early in the
35
+ * search order hide a current one later: with oam 0.9.0 installed in ~/.oam/bin
36
+ * and 0.15.2 on PATH, the launcher bound to 0.9.0 because installed locations
37
+ * are searched first.
38
+ *
39
+ * An OAM_BIN that does not exist, is below the floor, or will not run is always
40
+ * named on stderr, and discovery carries on. It used to stop everything: a typo
41
+ * in OAM_BIN meant Node, with no hint why. The discovered binaries that were
42
+ * passed over, and any .cmd/.bat shim on PATH, are named only when NO usable
43
+ * oam is found -- with 0.9.0 installed and 0.15.2 on PATH, stderr stays empty.
44
+ *
45
+ * ALREADY RUNNING ON OAM
46
+ * A host can resolve this package's `bin` and launch `oam run <this file>`
47
+ * instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
48
+ * regression matrix. This launcher used to discover oam and spawn it anyway,
49
+ * so one server cost two runtime boots: measured on Windows, oam.exe with a
50
+ * NESTED oam.exe + conhost.exe underneath it. When `process.versions.oam`
51
+ * clears the floor, the server is imported into THIS process exactly as the
52
+ * Node fallback is -- no discovery, no `oam --version` probe, no second oam.
53
+ * OAM_BIN is a discovery input, so it is not consulted on that path: the host
54
+ * has already chosen which oam runs.
55
+ *
56
+ * Nothing forces a re-spawn on a supported host: the spawn below passes oam no
57
+ * runtime flags -- there is no `--permission` sandbox to apply, see NO SANDBOX
58
+ * HERE -- so serving in-process drops nothing a fresh oam would have applied.
59
+ *
60
+ * A host oam BELOW the floor never serves. It used to, whenever discovery came
61
+ * up empty or found nothing usable. It now hands the server off to the newest
62
+ * usable oam, or to Node found on PATH, or exits with an error when there is
63
+ * neither.
64
+ *
65
+ * That handoff PIPES stdio rather than inheriting it. Before 0.9.0 oam treated
66
+ * `stdio: 'inherit'` as `'pipe'`, so an inherited handoff from such a host
67
+ * connected the child to pipes nobody reads: measured with a real oam 0.8.2
68
+ * host on aws-mcp's launcher, which this one shares, the MCP handshake never
69
+ * answered. Piping the streams explicitly completes it, to both oam and Node. A
70
+ * Node host keeps `inherit`, which hands over the same fds untouched.
71
+ *
25
72
  * NO SANDBOX HERE -- DELIBERATELY
26
73
  * The purpose of this server is to open outbound SSH to hosts the caller names
27
74
  * at run time and run commands there, so the net and child-process grants would
@@ -30,30 +77,40 @@
30
77
  * wired up here.
31
78
  *
32
79
  * MINIMUM OAM VERSION
33
- * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
34
- * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
35
- * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
36
- * behaved as `'pipe'`. This server shells out to a CLI on its
37
- * main paths, so those were reachable bugs rather than theoretical ones: an
38
- * argument containing shell metacharacters was re-split and executed.
39
- * An older oam is not an error: the launcher falls back to Node and says so on
40
- * stderr. Pinning the floor here is what makes that fallback automatic.
80
+ * The latest oam release, 0.15.2 -- bump OAM_MIN when oam ships a newer one.
81
+ * Only the current oam is used and verified; an older one is passed over.
82
+ * The floor is not cosmetic: before 0.9.0 `child_process.execFile` ran its
83
+ * arguments through a SHELL, `exec` accepted `timeout` and ignored it,
84
+ * `spawnSync` truncated at `maxBuffer` while reporting success, and
85
+ * `stdio: 'inherit'`/`'ignore'` both behaved as `'pipe'`. This server shells
86
+ * out to a CLI on its main paths, so those were reachable bugs rather than
87
+ * theoretical ones: an argument containing shell metacharacters was re-split
88
+ * and executed.
41
89
  *
42
90
  * SELECTION
43
- * SSH_MCP_RUNTIME=oam require oam; fail loudly if it is missing
44
- * SSH_MCP_RUNTIME=node never use oam
45
- * SSH_MCP_RUNTIME=auto prefer oam, silently fall back (default)
46
- * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
91
+ * SSH_MCP_RUNTIME=auto newest usable oam, else Node (default)
92
+ * SSH_MCP_RUNTIME=oam newest usable oam, else exit with an error
93
+ * (already running on oam at the floor satisfies it)
94
+ * SSH_MCP_RUNTIME=node Node: in THIS process on Node, handed off to Node
95
+ * on PATH when THIS process is oam
96
+ * OAM_BIN=/path/to/oam use this oam when it is usable, before discovery
97
+ * The value is case-insensitive; anything else behaves like `auto`.
47
98
  */
48
99
 
49
100
  import { execFileSync, spawn } from "node:child_process";
50
- import { existsSync } from "node:fs";
101
+ import { existsSync, realpathSync } from "node:fs";
51
102
  import { constants, homedir } from "node:os";
52
103
  import { delimiter, join } from "node:path";
53
104
  import { fileURLToPath } from "node:url";
54
105
 
55
106
  /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
56
- const OAM_MIN = [0, 9, 0];
107
+ const OAM_MIN = [0, 15, 2];
108
+
109
+ /**
110
+ * Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
111
+ * the bound only exists so a wedged binary on PATH cannot hang the launch.
112
+ */
113
+ const VERSION_PROBE_TIMEOUT_MS = 5_000;
57
114
 
58
115
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
59
116
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
@@ -63,74 +120,56 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
63
120
  const isWin = process.platform === "win32";
64
121
  const exe = isWin ? "oam.exe" : "oam";
65
122
 
123
+ /** Identity for de-duplicating paths: resolved, and case-folded on Windows. */
124
+ function pathKey(p) {
125
+ let key = p;
126
+ try {
127
+ key = realpathSync(p);
128
+ } catch {
129
+ // Unresolvable: fall back to the literal path.
130
+ }
131
+ return isWin ? key.toLowerCase() : key;
132
+ }
133
+
66
134
  /**
67
- * Locate an oam binary. Returns `{ path, shim }`:
68
- * path -- an oam this launcher can actually execute, or null
69
- * shim -- an oam-named `.cmd`/`.bat` seen on PATH and SKIPPED, or null
135
+ * Every oam binary discovery can see, in search order, de-duplicated. Stat-only,
136
+ * never a subprocess -- PATH is resolved manually rather than by spawning
137
+ * `which`/`where`.
138
+ *
139
+ * Installed locations come BEFORE PATH, so when two binaries report the same
140
+ * version the installed copy wins the tie. Someone who develops oam itself
141
+ * usually has oam/target/release on PATH, and cargo replaces that binary
142
+ * underneath running processes; the installed copy is the release the user
143
+ * actually installed. Both forms are checked on Windows: the installer defaults
144
+ * to %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
145
+ * OAM_INSTALL_DIR can pick either.
70
146
  *
71
- * The shim is reported rather than silently dropped: "no oam binary was found"
72
- * is the wrong thing to tell someone who has one installed in a shape we cannot
73
- * spawn. Every branch is a stat, never a subprocess.
147
+ * Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
148
+ * run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
149
+ * spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking the
150
+ * full PATHEXT list would hand back a path this launcher cannot execute. A
151
+ * skipped shim is still named on stderr when no usable oam is found -- see
152
+ * findOamShim.
74
153
  */
75
- function findOam() {
76
- // 1. Explicit override wins and is never second-guessed -- including a .cmd.
77
- // If it cannot be executed the version gate reports that specifically,
78
- // which is better than second-guessing an explicit instruction here.
79
- const override = process.env.OAM_BIN;
80
- if (override) return { path: existsSync(override) ? override : null, shim: null };
81
-
82
- // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
83
- // usually has oam/target/release on PATH, and a build directory is the
84
- // wrong thing for a user-facing launcher to bind to: cargo replaces the
85
- // binary underneath running processes, and the dev build is not the
86
- // release the user installed. Preferring the installed copy makes the
87
- // default path "what a normal user has", and OAM_BIN remains the way to
88
- // point deliberately at a dev build.
89
- //
90
- // Both forms are checked on Windows: the installer defaults to
91
- // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
92
- // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
93
- // install.
154
+ function discoverOamPaths() {
94
155
  const installed = [join(homedir(), ".oam", "bin", exe)];
95
156
  if (isWin) {
96
157
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
97
158
  }
98
- for (const candidate of installed) {
99
- if (existsSync(candidate)) return { path: candidate, shim: null };
100
- }
101
-
102
- // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
103
- // would cost a subprocess on every launch just to decide whether to spawn.
104
- //
105
- // Windows: only `.exe` is RETURNED -- deliberately narrower than PATHEXT.
106
- // Node refuses to run a `.cmd`/`.bat` through execFile/spawn without
107
- // `shell: true` (EINVAL, and for spawn it throws SYNCHRONOUSLY rather than
108
- // emitting 'error'), so returning one would hand back a path this launcher
109
- // cannot execute -- discovery has to agree with execution. `exe` is also
110
- // what the installed-location checks above look for, so both discovery
111
- // paths accept exactly the same shapes.
112
- //
113
- // A shim is still NOTED, though. An npm-style install puts `oam.cmd` on
114
- // PATH, and staying silent about it means auto mode degrades with no
115
- // explanation and `SSH_MCP_RUNTIME=oam` claims nothing was found -- both
116
- // of which send someone to reinstall an oam they already have.
117
- let shim = null;
118
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
119
- if (!dir) continue;
120
- const candidate = join(dir, exe);
121
- if (existsSync(candidate)) return { path: candidate, shim: null };
122
- if (isWin && shim === null) {
123
- for (const ext of [".cmd", ".bat"]) {
124
- const alt = join(dir, `oam${ext}`);
125
- if (existsSync(alt)) {
126
- shim = alt;
127
- break;
128
- }
129
- }
130
- }
159
+ const onPath = (process.env.PATH ?? "")
160
+ .split(delimiter)
161
+ .filter(Boolean)
162
+ .map((dir) => join(dir, exe));
163
+ const seen = new Set();
164
+ const found = [];
165
+ for (const candidate of [...installed, ...onPath]) {
166
+ if (!existsSync(candidate)) continue;
167
+ const key = pathKey(candidate);
168
+ if (seen.has(key)) continue;
169
+ seen.add(key);
170
+ found.push(candidate);
131
171
  }
132
-
133
- return { path: null, shim };
172
+ return found;
134
173
  }
135
174
 
136
175
  /**
@@ -158,19 +197,31 @@ async function errSync(message) {
158
197
  }
159
198
 
160
199
  /**
161
- * `oam --version` -> [major, minor, patch], or null when it cannot be read.
200
+ * Version text -> [major, minor, patch], or null when it holds no version.
162
201
  * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
202
+ *
203
+ * Shared by the two places a version is read -- a discovered binary's
204
+ * `oam --version` output ("oam 0.15.1") and the host's own
205
+ * `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
206
+ * version string means, or which floor it has to clear.
163
207
  */
208
+ function parseVersion(text) {
209
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
210
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
211
+ }
212
+
213
+ /** `oam --version` -> [major, minor, patch], or null when it cannot be read. */
164
214
  function oamVersion(cmd) {
165
215
  try {
166
216
  const out = execFileSync(cmd, ["--version"], {
167
217
  encoding: "utf-8",
168
218
  stdio: ["ignore", "pipe", "ignore"],
219
+ timeout: VERSION_PROBE_TIMEOUT_MS,
220
+ windowsHide: true,
169
221
  });
170
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
171
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
222
+ return parseVersion(out);
172
223
  } catch {
173
- // Not executable, wrong arch, or deleted since the stat. Caller degrades.
224
+ // Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
174
225
  return null;
175
226
  }
176
227
  }
@@ -185,6 +236,148 @@ function atLeast(v, min) {
185
236
  return true;
186
237
  }
187
238
 
239
+ /**
240
+ * The newest candidate at or above the floor, or null. `candidates` is
241
+ * `{ path, version }[]` in search order, `version` null when unreadable.
242
+ * Strictly-greater replaces, so a tie keeps the earlier candidate.
243
+ *
244
+ * Pure on purpose, like runtimePlan: the choice is testable without binaries.
245
+ */
246
+ function pickNewest(candidates) {
247
+ let best = null;
248
+ for (const candidate of candidates) {
249
+ if (!atLeast(candidate.version, OAM_MIN)) continue;
250
+ if (!best || !atLeast(best.version, candidate.version)) best = candidate;
251
+ }
252
+ return best;
253
+ }
254
+
255
+ /**
256
+ * Where the server runs, decided BEFORE any discovery:
257
+ * "in-process" import it into THIS process
258
+ * "discover" choose an oam and spawn it, or fall back to Node
259
+ * "handoff-node" hand it off to Node on PATH: THIS process is an oam, and
260
+ * Node was asked for
261
+ *
262
+ * `hostOam` is `process.versions.oam`: oam's own key, absent on Node. An oam
263
+ * host whose version cannot be read is treated as below the floor -- it never
264
+ * proved it is a supported oam -- and a host below the floor takes the
265
+ * discovery path, whose every outcome on an oam host is a spawn or an error
266
+ * exit, never an in-process serve. The floor is OAM_MIN itself, not a parameter, so a host oam
267
+ * and a discovered one can never be held to different minimums. There is no
268
+ * sandbox input because this launcher has no sandbox; see ALREADY RUNNING ON
269
+ * OAM above for why nothing else forces a spawn.
270
+ *
271
+ * Pure on purpose: every input is passed in, so the whole decision is testable
272
+ * without booting a runtime.
273
+ */
274
+ function runtimePlan({ mode, hostOam }) {
275
+ const onOam = hostOam !== undefined;
276
+ if (mode === "node") return onOam ? "handoff-node" : "in-process";
277
+ return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
278
+ }
279
+
280
+ /**
281
+ * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
282
+ * cannot spawn. Looked up only when no usable oam was found, and then reported
283
+ * rather than ignored, because "no oam binary was found" reads as "install oam"
284
+ * -- the one thing that will not help. An npm-style install puts `oam.cmd` on
285
+ * PATH, and staying silent about it sends someone to reinstall an oam they
286
+ * already have. With a usable oam.exe chosen, the shim goes unmentioned: nothing
287
+ * degraded. Windows only; there is no such shim concept on POSIX.
288
+ */
289
+ function findOamShim() {
290
+ if (!isWin) return null;
291
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
292
+ if (!dir) continue;
293
+ for (const ext of [".cmd", ".bat"]) {
294
+ const candidate = join(dir, `oam${ext}`);
295
+ if (existsSync(candidate)) return candidate;
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+
301
+ /** A Node binary on PATH, or null. Stat-only; used only when THIS process is oam. */
302
+ function findNodeOnPath() {
303
+ const name = isWin ? "node.exe" : "node";
304
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
305
+ if (!dir) continue;
306
+ const candidate = join(dir, name);
307
+ if (existsSync(candidate)) return candidate;
308
+ }
309
+ return null;
310
+ }
311
+
312
+ /**
313
+ * Why a candidate was passed over, for stderr.
314
+ *
315
+ * Two different causes, and they need different remedies. A null `version` is
316
+ * NOT "old": oamVersion returns null when the binary could not be run at all
317
+ * (not executable, wrong arch, wedged, deleted between the stat and the probe)
318
+ * or when its --version output did not parse. Telling that user to
319
+ * `oam self-update` sends them after the one cause it definitely is not, so the
320
+ * wording splits here, and so does the remedy in `remedyFor`.
321
+ */
322
+ function unusableReason(path, version, label = path) {
323
+ const min = OAM_MIN.join(".");
324
+ return version
325
+ ? `${label} is oam ${version.join(".")}, older than ${min}`
326
+ : `${label} could not be run, or did not report a version this launcher understands`;
327
+ }
328
+
329
+ /**
330
+ * Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
331
+ * binary. Returns the choice (or null) plus what stderr needs:
332
+ * overrideNote why OAM_BIN was passed over, or null
333
+ * skipped why each discovered binary was passed over, when none was chosen
334
+ * passedOver the `version` of every existing binary rejected (OAM_BIN
335
+ * included), so a hard failure can name the right remedy
336
+ * overrideMissing OAM_BIN was set to a path that does not exist
337
+ */
338
+ function chooseOam() {
339
+ const override = process.env.OAM_BIN;
340
+ let overrideNote = null;
341
+ let overrideMissing = false;
342
+ const passedOver = [];
343
+ if (override) {
344
+ if (!existsSync(override)) {
345
+ overrideNote = `OAM_BIN=${override} does not exist`;
346
+ overrideMissing = true;
347
+ } else {
348
+ const version = oamVersion(override);
349
+ if (atLeast(version, OAM_MIN)) {
350
+ return { chosen: { path: override, version }, overrideNote, skipped: [], passedOver, overrideMissing };
351
+ }
352
+ overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
353
+ passedOver.push(version);
354
+ }
355
+ }
356
+ const overrideKey = override ? pathKey(override) : null;
357
+ const candidates = discoverOamPaths()
358
+ .filter((path) => pathKey(path) !== overrideKey)
359
+ .map((path) => ({ path, version: oamVersion(path) }));
360
+ const chosen = pickNewest(candidates);
361
+ const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
362
+ if (!chosen) passedOver.push(...candidates.map((c) => c.version));
363
+ return { chosen, overrideNote, skipped, passedOver, overrideMissing };
364
+ }
365
+
366
+ /** What would fix "no usable oam", one line per cause that was actually seen. */
367
+ function remedyFor({ passedOver, overrideMissing, shim }) {
368
+ const lines = [];
369
+ if (passedOver.some((v) => v !== null)) {
370
+ lines.push(`Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer.\n`);
371
+ }
372
+ if (passedOver.some((v) => v === null)) {
373
+ lines.push("Check that it is an executable oam binary for this platform.\n");
374
+ }
375
+ if (overrideMissing) lines.push("Point OAM_BIN at an existing oam binary, or unset it.\n");
376
+ if (lines.length === 0 && !shim) lines.push("Install oam from https://oamjs.org, or set OAM_BIN=/path/to/oam.\n");
377
+ lines.push("Or use SSH_MCP_RUNTIME=node to run on Node.\n");
378
+ return lines.join("");
379
+ }
380
+
188
381
  /** Run the server in THIS process. The zero-overhead fallback. */
189
382
  async function runInProcess() {
190
383
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -201,206 +394,253 @@ async function runInProcess() {
201
394
  await import(SERVER_URL.href);
202
395
  }
203
396
 
397
+ // ONE reporter for every failed in-process fallback, so the sync-throw path and
398
+ // the 'error'-event path in launchChild cannot drift apart. runInProcess() is a
399
+ // bare import() that rejects when dist/index.js is missing or throws at load,
400
+ // and at ESM top level an unhandled rejection is an uncaught exception -- it
401
+ // kills the process and replaces this launcher's diagnostic with a raw stack
402
+ // trace, which is the exact failure this handling exists to prevent.
403
+ const fallbackFailed = (e) => {
404
+ process.stderr.write(`ssh-mcp: fallback to Node failed (${e?.message ?? e})\n`);
405
+ process.exitCode = 1;
406
+ };
407
+
408
+ /**
409
+ * Spawn the server in a child runtime and mirror its lifetime.
410
+ *
411
+ * `onLaunchFailed(err)` runs when the child could not be started at all; it is
412
+ * never called once the child is running, which would double-start the server
413
+ * on the same stdio. Every "could not be executed" outcome lands there: the
414
+ * synchronous throw from spawn() and the async 'error' event mean the same
415
+ * thing and must degrade the same way.
416
+ */
417
+ async function launchChild(cmd, args, onLaunchFailed) {
418
+ // THIS process being an oam means one below the floor (a supported oam host
419
+ // serves in-process) or any oam under SSH_MCP_RUNTIME=node, and an old oam's
420
+ // `stdio: 'inherit'` does not hand over the fds. Pipe explicitly from every
421
+ // oam host; see ALREADY RUNNING ON OAM.
422
+ const piped = process.versions.oam !== undefined;
423
+ let child = null;
424
+ try {
425
+ child = spawn(cmd, args, {
426
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
427
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
428
+ // server's shutdown path. Piping preserves both as well: bytes are copied
429
+ // unchanged, and stdin's end propagates to the child.
430
+ stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
431
+ env: process.env,
432
+ windowsHide: true,
433
+ });
434
+ } catch (err) {
435
+ // spawn() THROWS for some failures instead of emitting 'error', and the
436
+ // 'error' listener is registered AFTER this call, so it can never observe
437
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
438
+ // instead of falling back.
439
+ //
440
+ // Belt-and-braces, deliberately: an oam reaching this line already answered
441
+ // a version probe, so the shapes that throw synchronously (a .cmd/.bat Node
442
+ // refuses with EINVAL) were diverted by the version gate -- and a Node
443
+ // handoff only ever names node.exe, never a shim. A deleted binary (ENOENT)
444
+ // or a permission failure (EACCES) is routed to the async 'error' event
445
+ // instead. What is left is a genuine TOCTOU: the binary
446
+ // replaced between the probe and the spawn. Cheap to keep, and the
447
+ // alternative is a stack trace in a stdio server.
448
+ await onLaunchFailed(err).catch(fallbackFailed);
449
+ return;
450
+ }
451
+
452
+ // If the runtime cannot be executed at all (deleted between the version probe
453
+ // and the spawn, wrong arch, permission), fall back rather than failing the
454
+ // whole server. `spawned` prevents falling back AFTER the child started.
455
+ //
456
+ // Everything that assumes a live child waits for 'spawn'. A failed spawn
457
+ // still emits 'close' (after 'error', with the negative errno as its code), so
458
+ // an unguarded close handler would process.exit() out from under the fallback
459
+ // onLaunchFailed has just started. Piping and signal forwarding wait as well,
460
+ // so a child that never ran is never handed the host's stdin or its signals:
461
+ // nothing in this file reads process.stdin before 'spawn'.
462
+ let spawned = false;
463
+ child.on("spawn", () => {
464
+ spawned = true;
465
+ if (piped) {
466
+ process.stdin.pipe(child.stdin);
467
+ child.stdout.pipe(process.stdout);
468
+ child.stderr.pipe(process.stderr);
469
+ }
470
+ forwardSignals();
471
+ });
472
+ child.on("error", (err) => {
473
+ if (spawned) return;
474
+ // Handle the rejection instead of discarding the promise: a failing
475
+ // fallback used to escape as an unhandled rejection, replacing this
476
+ // launcher's diagnostic with a raw stack trace.
477
+ onLaunchFailed(err).catch(fallbackFailed);
478
+ });
479
+ // A child that exits before reading everything closes its stdin; the
480
+ // resulting EPIPE is not worth crashing over.
481
+ child.stdin?.on("error", () => {});
482
+
483
+ // Forward termination so the server's own shutdown path runs in the child
484
+ // rather than the child being orphaned.
485
+ //
486
+ // Registering ANY handler for these suppresses Node's default
487
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
488
+ // `child.killed` only records that kill() was CALLED, never that the child
489
+ // is gone, so gating on it swallows every signal after the first and wedges
490
+ // the launcher with no escape hatch.
491
+ //
492
+ // Escalation is driven by a TIMER, not by counting signals, and not by
493
+ // comparing timestamps. Counting is ambiguous: a supervisor routinely sends
494
+ // SIGINT then SIGTERM milliseconds apart, and a terminal Ctrl-C reaches the
495
+ // whole process group, so the child usually gets its own copy alongside ours
496
+ // -- reading "a second signal" as impatience hard-kills a child that is
497
+ // already shutting down cleanly. A timer makes the count irrelevant: ONE
498
+ // press is enough, and a wedged child dies on schedule without the user
499
+ // having to guess how many times to press. It also sidesteps the wall clock
500
+ // -- setTimeout is monotonic, so a clock step cannot mis-gate the window in
501
+ // either direction.
502
+ //
503
+ // POSIX vs Windows, and why we do not forward on Windows.
504
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
505
+ // is what lets the child run its shutdown. On Windows there are no POSIX
506
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
507
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs it
508
+ // and dies with code=null). Forwarding there would ABORT the graceful
509
+ // shutdown the console's own Ctrl-C just started, skipping the child's
510
+ // process.on("exit") backstop -- which is what reaps an ssh-agent this server
511
+ // spawned (killStartedAgent, src/env.ts) -- and leak the daemon. The console
512
+ // has already notified the child, so on Windows the timer below is the only
513
+ // kill we issue.
514
+ //
515
+ // The window comfortably exceeds the child's own shutdown budget
516
+ // (server.close -> pool.drain -> killStartedAgent -> ~100ms FIN grace).
517
+ const ESCALATE_AFTER_MS = 2000;
518
+ let escalation = null;
519
+ function forwardSignals() {
520
+ for (const sig of ["SIGINT", "SIGTERM"]) {
521
+ process.on(sig, () => {
522
+ // No try/catch: kill() on an already-exited child returns false, it does
523
+ // not throw. It throws only for a signal the platform does not know,
524
+ // which SIGINT/SIGTERM/SIGKILL never are.
525
+ if (!isWin) child.kill(sig);
526
+ if (escalation) return; // already counting down; further signals are noise
527
+ escalation = setTimeout(() => {
528
+ // Still here after its grace window. Stop waiting on it.
529
+ child.kill("SIGKILL");
530
+ process.exit(128 + (constants.signals[sig] ?? 15));
531
+ }, ESCALATE_AFTER_MS);
532
+ });
533
+ }
534
+ }
535
+
536
+ // Piped: wait for 'close', so the child's last stdout bytes are copied out
537
+ // before this process exits. Inherited: 'exit' is enough, the fds were never
538
+ // ours to drain. Either way, only for a child that actually ran -- see the
539
+ // 'spawn' handler above.
540
+ child.on(piped ? "close" : "exit", (code, signal) => {
541
+ if (!spawned) return;
542
+ if (escalation) clearTimeout(escalation);
543
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
544
+ // conventional shell exit status rather than a bare 0.
545
+ if (signal) {
546
+ process.exit(128 + (constants.signals[signal] ?? 15));
547
+ }
548
+ process.exit(code ?? 0);
549
+ });
550
+ }
551
+
552
+ /**
553
+ * Hand the server to Node on PATH. Only reachable when THIS process is oam --
554
+ * one below the floor, or any oam under SSH_MCP_RUNTIME=node -- so there is no
555
+ * in-process option left.
556
+ */
557
+ async function handOffToNode(reason) {
558
+ const node = findNodeOnPath();
559
+ if (!node) {
560
+ // Two ways here, two remedies: an oam below the floor is fixed by updating
561
+ // it, while SSH_MCP_RUNTIME=node on a supported oam asked for Node outright.
562
+ const remedy = reason
563
+ ? `Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer, or launch this command with node.\n`
564
+ : "Put Node on PATH, or unset SSH_MCP_RUNTIME to serve on this oam.\n";
565
+ await errSync(
566
+ `ssh-mcp: ${reason || `SSH_MCP_RUNTIME=node on oam ${process.versions.oam}`}, and no Node was found on PATH to run the server instead.\n${remedy}`,
567
+ );
568
+ process.exit(1);
569
+ }
570
+ if (reason) await errSync(`ssh-mcp: ${reason}; running on ${node} instead.\n`);
571
+ await launchChild(node, [SERVER_ENTRY, ...process.argv.slice(2)], async (err) => {
572
+ await errSync(`ssh-mcp: failed to launch Node at ${node} (${err?.message ?? err})\n`);
573
+ process.exit(1);
574
+ });
575
+ }
576
+
577
+ /**
578
+ * No usable oam, or the chosen one would not start, under a mode that allows
579
+ * Node. `why` finishes the handoff note on an oam host, so a failed spawn --
580
+ * already named on stderr -- is not then reported as nothing being found.
581
+ */
582
+ async function fallBackToNode(hostOam, why) {
583
+ if (hostOam === undefined) {
584
+ await runInProcess();
585
+ return;
586
+ }
587
+ await handOffToNode(`this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}, and ${why}`);
588
+ }
589
+
204
590
  const mode = (process.env.SSH_MCP_RUNTIME ?? "auto").toLowerCase();
591
+ const hostOam = process.versions.oam;
592
+ const plan = runtimePlan({ mode, hostOam });
205
593
 
206
- if (mode === "node") {
594
+ if (plan === "in-process") {
207
595
  await runInProcess();
596
+ } else if (plan === "handoff-node") {
597
+ const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
598
+ await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
208
599
  } else {
209
- const { path: oam, shim: oamShim } = findOam();
210
- // Read the version ONCE, and only when discovery found something: the gate
211
- // below has to tell "too old" apart from "could not be read at all", and
212
- // re-probing inside the branch would cost a second subprocess.
213
- //
214
- // Discovery itself stays stat-only; this is the first subprocess. It is paid
215
- // on every launch that finds an oam -- including the ones that go on to fall
216
- // back to Node -- not only the ones that end up spawning it. Measured 26ms
217
- // median (n=12, windows-arm64), once per MCP session.
218
- const found = oam ? oamVersion(oam) : null;
600
+ const { chosen, overrideNote, skipped, passedOver, overrideMissing } = chooseOam();
219
601
 
220
- if (!oam) {
221
- // An oam-named .cmd/.bat on PATH is a real install in a shape this launcher
222
- // cannot spawn. Naming it turns "no oam binary was found" -- which reads as
223
- // "install oam", the one thing that will not help -- into something the user
224
- // can act on.
225
- const shimNote = oamShim
226
- ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
227
- "Install the native oam binary, or point OAM_BIN at one.\n"
228
- : "";
602
+ if (chosen) {
603
+ if (overrideNote)
604
+ await errSync(`ssh-mcp: ${overrideNote}; using ${chosen.path} (oam ${chosen.version.join(".")}).\n`);
605
+ // `--` separates oam's own flags from the script's argv, so `ssh-mcp
606
+ // --version` and any host-supplied flags survive the hop unchanged.
607
+ await launchChild(chosen.path, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], async (err) => {
608
+ if (mode === "oam") {
609
+ await errSync(`ssh-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err})\n`);
610
+ process.exit(1);
611
+ }
612
+ await errSync(`ssh-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err}); using Node instead.\n`);
613
+ await fallBackToNode(hostOam, "the newer oam would not start");
614
+ });
615
+ } else {
616
+ const shim = findOamShim();
617
+ const notes = [
618
+ ...(overrideNote ? [overrideNote] : []),
619
+ ...skipped,
620
+ ...(shim
621
+ ? [
622
+ `found ${shim}, but Node cannot execute a .cmd/.bat directly -- install the native oam binary, or point OAM_BIN at one`,
623
+ ]
624
+ : []),
625
+ ];
229
626
  if (mode === "oam") {
230
627
  // Explicitly demanded, so this is a real misconfiguration.
231
628
  await errSync(
232
- `ssh-mcp: SSH_MCP_RUNTIME=oam but no runnable oam binary was found.\n${shimNote}` +
233
- "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use SSH_MCP_RUNTIME=node.\n",
629
+ `ssh-mcp: SSH_MCP_RUNTIME=oam but no usable oam (${OAM_MIN.join(".")} or newer) was found.\n` +
630
+ notes.map((note) => ` ${note}\n`).join("") +
631
+ remedyFor({ passedOver, overrideMissing, shim }),
234
632
  );
235
633
  process.exit(1);
236
634
  }
237
635
  // auto: falling back is correct, but silence is how someone never learns
238
- // their oam install is a shape this launcher skips. Only worth saying when
239
- // there was actually something to skip.
240
- if (oamShim) await errSync(`ssh-mcp: ${shimNote}Using Node instead.\n`);
241
- await runInProcess();
242
- } else if (!atLeast(found, OAM_MIN)) {
243
- const min = OAM_MIN.join(".");
244
- // Two different causes reach this branch and they need different remedies.
245
- // `found === null` is NOT "old": oamVersion returns null when the binary
246
- // could not be run at all (not executable, wrong arch, a .cmd/.bat Node
247
- // refuses, deleted between the stat and the probe) or when its --version
248
- // output did not parse. Telling that user to `oam self-update` sends them
249
- // after the one cause it definitely is not, so the wording splits here.
250
- const detail = found
251
- ? `${oam} is oam ${found.join(".")}, older than ${min}`
252
- : `${oam} could not be run, or did not report a version this launcher understands`;
253
- const remedy = found
254
- ? "Run `oam self-update`, or use SSH_MCP_RUNTIME=node.\n"
255
- : "Check that it is an executable oam binary for this platform, or use SSH_MCP_RUNTIME=node.\n";
256
- if (mode === "oam") {
257
- await errSync(`ssh-mcp: SSH_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
258
- process.exit(1);
259
- }
260
- // auto: neither cause is worth failing over -- prefer Node. Say so, because
261
- // a silent downgrade is how someone keeps running an oam they meant to
262
- // update, or never learns their oam is unexecutable. stdout carries the MCP
263
- // frames, so stderr is the only safe channel.
636
+ // their OAM_BIN is wrong, their oam is too old to use, or their install is a
637
+ // shape this launcher skips. Only worth saying when something was skipped.
264
638
  //
265
639
  // errSync, not process.stderr.write: an exit DOES follow, just indirectly.
266
640
  // runInProcess() imports dist/index.js, whose top level answers `--version`
267
641
  // with console.log + process.exit(0) (src/index.ts) -- and that exit
268
642
  // truncates a pending async stderr write on Windows TTYs and pipes.
269
- await errSync(`ssh-mcp: ${detail}; using Node instead.\n`);
270
- await runInProcess();
271
- } else {
272
- // Every "oam could not be executed" outcome lands here: the synchronous
273
- // throw from spawn() and the async 'error' event both mean the same thing
274
- // and must degrade the same way, so the handling lives in one place.
275
- // errSync rather than process.stderr.write because stderr is async for
276
- // TTYs and pipes on Windows and the process.exit below truncates pending
277
- // writes -- the same reason the two branches above use it.
278
- const launchFailed = async (err) => {
279
- if (mode === "oam") {
280
- await errSync(`ssh-mcp: failed to launch oam (${err?.message ?? err})\n`);
281
- process.exit(1);
282
- }
283
- await runInProcess();
284
- };
285
-
286
- // ONE reporter shared by both launchFailed call sites below, so the
287
- // sync-throw path and the 'error'-event path cannot drift apart. Either can
288
- // reject: in auto mode launchFailed awaits runInProcess(), a bare import()
289
- // that rejects whenever dist/index.js is missing or throws at load. At ESM
290
- // top level an unhandled rejection is an uncaught exception -- it kills the
291
- // process and replaces this launcher's diagnostic with a raw stack trace,
292
- // which is the exact failure this handling exists to prevent.
293
- const fallbackFailed = (e) => {
294
- process.stderr.write(`ssh-mcp: fallback to Node failed (${e?.message ?? e})\n`);
295
- process.exitCode = 1;
296
- };
297
-
298
- // `--` separates oam's own flags from the script's argv, so `ssh-mcp
299
- // --version` and any host-supplied flags survive the hop unchanged.
300
- let child = null;
301
- try {
302
- child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
303
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
304
- // stdin/stdout is untouched and the host's stdin-close still reaches the
305
- // server's shutdown path.
306
- stdio: "inherit",
307
- env: process.env,
308
- windowsHide: true,
309
- });
310
- } catch (err) {
311
- // spawn() THROWS for some failures instead of emitting 'error', and the
312
- // 'error' listener is registered AFTER this call, so it can never observe
313
- // one -- an uncaught throw here kills the launcher with a raw stack trace
314
- // instead of falling back to Node.
315
- //
316
- // Belt-and-braces, deliberately: reaching this line already means
317
- // execFileSync ran this same binary and read a version from it, so the
318
- // shapes that throw synchronously (a .cmd/.bat Node refuses with EINVAL)
319
- // have been diverted by the version gate above, and the ones the comments
320
- // below name -- deleted (ENOENT), permission (EACCES) -- are among the
321
- // errnos Node routes to the async 'error' event instead. What is left is
322
- // a genuine TOCTOU: the binary replaced between the probe and the spawn.
323
- // Cheap to keep, and the alternative is a stack trace in a stdio server.
324
- await launchFailed(err).catch(fallbackFailed);
325
- }
326
-
327
- if (child) {
328
- // If oam cannot be executed at all (deleted between the stat and the spawn,
329
- // wrong arch, permission), fall back rather than failing the whole server.
330
- // `spawned` prevents falling back AFTER the child started, which would
331
- // double-start the server on the same stdio.
332
- let spawned = false;
333
- child.on("spawn", () => {
334
- spawned = true;
335
- });
336
- child.on("error", (err) => {
337
- if (spawned) return;
338
- // Handle the rejection instead of discarding the promise: a failing
339
- // runInProcess() used to escape as an unhandled rejection, replacing
340
- // this launcher's diagnostic with a raw stack trace.
341
- launchFailed(err).catch(fallbackFailed);
342
- });
343
-
344
- // Forward termination so the server's own shutdown path runs in the child
345
- // rather than the child being orphaned.
346
- //
347
- // Registering ANY handler for these suppresses Node's default
348
- // terminate-on-signal, so the parent's exit has to be arranged
349
- // explicitly. `child.killed` only records that kill() was CALLED, never
350
- // that the child is gone, so gating on it swallows every signal after the
351
- // first and wedges the launcher with no escape hatch.
352
- //
353
- // Escalation is driven by a TIMER, not by counting signals, and not by
354
- // comparing timestamps. Counting is ambiguous: a supervisor routinely
355
- // sends SIGINT then SIGTERM milliseconds apart, and a terminal Ctrl-C
356
- // reaches the whole process group, so the child usually gets its own copy
357
- // alongside ours -- reading "a second signal" as impatience hard-kills a
358
- // child that is already shutting down cleanly. A timer makes the count
359
- // irrelevant: ONE press is enough, and a wedged child dies on schedule
360
- // without the user having to guess how many times to press. It also
361
- // sidesteps the wall clock -- setTimeout is monotonic, so a clock step
362
- // cannot mis-gate the window in either direction.
363
- //
364
- // POSIX vs Windows, and why we do not forward on Windows.
365
- // On POSIX child.kill(sig) delivers a real, catchable signal, so
366
- // forwarding is what lets the child run its shutdown. On Windows there
367
- // are no POSIX signals: child.kill IGNORES the name and calls
368
- // TerminateProcess -- an immediate hard kill (verified: a child with a
369
- // SIGTERM handler never runs it and dies with code=null). Forwarding
370
- // there would ABORT the graceful shutdown the console's own Ctrl-C just
371
- // started, skipping the child's process.on("exit") backstop -- which is
372
- // what reaps an ssh-agent this server spawned (killStartedAgent,
373
- // src/env.ts) -- and leak the daemon. The console has already notified
374
- // the child, so on Windows the timer below is the only kill we issue.
375
- //
376
- // The window comfortably exceeds the child's own shutdown budget
377
- // (server.close -> pool.drain -> killStartedAgent -> ~100ms FIN grace).
378
- const ESCALATE_AFTER_MS = 2000;
379
- let escalation = null;
380
- for (const sig of ["SIGINT", "SIGTERM"]) {
381
- process.on(sig, () => {
382
- // No try/catch: kill() on an already-exited child returns false, it
383
- // does not throw. It throws only for a signal the platform does not
384
- // know, which SIGINT/SIGTERM/SIGKILL never are.
385
- if (!isWin) child.kill(sig);
386
- if (escalation) return; // already counting down; further signals are noise
387
- escalation = setTimeout(() => {
388
- // Still here after its grace window. Stop waiting on it.
389
- child.kill("SIGKILL");
390
- process.exit(128 + (constants.signals[sig] ?? 15));
391
- }, ESCALATE_AFTER_MS);
392
- });
393
- }
394
-
395
- child.on("exit", (code, signal) => {
396
- if (escalation) clearTimeout(escalation);
397
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
398
- // conventional shell exit status rather than a bare 0.
399
- if (signal) {
400
- process.exit(128 + (constants.signals[signal] ?? 15));
401
- }
402
- process.exit(code ?? 0);
403
- });
404
- }
643
+ if (notes.length > 0) await errSync(`ssh-mcp: ${notes.join("; ")}; using Node instead.\n`);
644
+ await fallBackToNode(hostOam, "no newer oam was found").catch(fallbackFailed);
405
645
  }
406
646
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "mcpName": "io.github.YawLabs/ssh-mcp",
5
5
  "description": "SSH MCP server: run remote commands, transfer files over SFTP, manage ssh-agent keys and known_hosts, and auto-diagnose SSH failures.",
6
6
  "type": "module",