@yawlabs/ssh-mcp 0.15.2 → 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 +19 -7
  2. package/bin/ssh-mcp.mjs +478 -288
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -305,15 +305,27 @@ for (const check of report.checks) {
305
305
 
306
306
  ## Runtime selection
307
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. Two environment variables control the choice; set them in your MCP client's `env` block:
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:
309
311
 
310
312
  - `SSH_MCP_RUNTIME` — which runtime to use. Case-insensitive; any value other than `oam` or `node` behaves as `auto`.
311
- - `auto` (default) — use oam if it is usable, otherwise fall back to Node.
312
- - `oam` — require oam. Fails loudly: if oam is missing or unusable, the launcher prints the reason to stderr and exits with status 1 instead of starting the server.
313
- - `node` — never use oam. The launcher does not look for it, and `OAM_BIN` is ignored.
314
- - `OAM_BIN` — path to a specific oam binary. When set to a non-empty value it wins over discovery and nothing else is searched, so a path that does not exist counts as no oam found even if a working oam is installed elsewhere. Without it, the launcher takes the first oam it finds in `%LOCALAPPDATA%\oam\bin` (Windows only), then `~/.oam/bin`, then `PATH`, and does not keep looking if that one is unusable.
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.
315
327
 
316
- oam must be **0.9.0 or newer**. By default an unusable oam is not an error: in `auto` mode the launcher falls back to Node silently when no oam is found (including an `OAM_BIN` path that does not exist), or with a note on stderr when it finds one it cannot use: older than 0.9.0, not runnable, or on Windows only an `oam.cmd`/`oam.bat` shim on `PATH`. With `SSH_MCP_RUNTIME=oam`, each of these cases exits with status 1 instead.
328
+ For example, to always run on Node:
317
329
 
318
330
  ```json
319
331
  {
@@ -333,7 +345,7 @@ On Windows, add the same `env` block to the `cmd /c` form from [Quick start](#qu
333
345
 
334
346
  - Node.js 18+
335
347
  - SSH client installed (for diagnostics and environment management)
336
- - Optional: [oam](https://oamjs.org) 0.9.0+ — see [Runtime selection](#runtime-selection)
348
+ - Optional: [oam](https://oamjs.org) 0.15.2+ — see [Runtime selection](#runtime-selection)
337
349
 
338
350
  ## License
339
351
 
package/bin/ssh-mcp.mjs CHANGED
@@ -2,42 +2,72 @@
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
+ *
25
45
  * ALREADY RUNNING ON OAM
26
46
  * A host can resolve this package's `bin` and launch `oam run <this file>`
27
47
  * instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
28
48
  * regression matrix. This launcher used to discover oam and spawn it anyway,
29
49
  * so one server cost two runtime boots: measured on Windows, oam.exe with a
30
- * NESTED oam.exe + conhost.exe underneath it. Now, when `process.versions.oam`
31
- * clears the same MINIMUM OAM VERSION a discovered binary has to, the server is
32
- * imported into THIS process exactly as the Node fallback is -- no discovery,
33
- * no `oam --version` probe, no second oam. OAM_BIN is a discovery input, so it
34
- * is not consulted on that path: the host has already chosen which oam runs.
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.
35
64
  *
36
- * A host oam below the floor still takes the discovery path exactly as it
37
- * always did. Nothing else has to keep spawning on oam: the spawn below passes
38
- * oam no runtime flags -- there is no `--permission` sandbox to apply, see NO
39
- * SANDBOX HERE -- so serving in-process drops nothing a fresh oam would have
40
- * applied.
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.
41
71
  *
42
72
  * NO SANDBOX HERE -- DELIBERATELY
43
73
  * The purpose of this server is to open outbound SSH to hosts the caller names
@@ -47,31 +77,40 @@
47
77
  * wired up here.
48
78
  *
49
79
  * MINIMUM OAM VERSION
50
- * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
51
- * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
52
- * `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
53
- * behaved as `'pipe'`. This server shells out to a CLI on its
54
- * main paths, so those were reachable bugs rather than theoretical ones: an
55
- * argument containing shell metacharacters was re-split and executed.
56
- * An older oam is not an error: the launcher falls back to Node and says so on
57
- * 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.
58
89
  *
59
90
  * SELECTION
60
- * SSH_MCP_RUNTIME=oam require oam; fail loudly if it is missing
61
- * (already running on oam satisfies it)
62
- * SSH_MCP_RUNTIME=node never use oam
63
- * SSH_MCP_RUNTIME=auto prefer oam, silently fall back (default)
64
- * 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`.
65
98
  */
66
99
 
67
100
  import { execFileSync, spawn } from "node:child_process";
68
- import { existsSync } from "node:fs";
101
+ import { existsSync, realpathSync } from "node:fs";
69
102
  import { constants, homedir } from "node:os";
70
103
  import { delimiter, join } from "node:path";
71
104
  import { fileURLToPath } from "node:url";
72
105
 
73
106
  /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
74
- 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;
75
114
 
76
115
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
77
116
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
@@ -81,74 +120,56 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
81
120
  const isWin = process.platform === "win32";
82
121
  const exe = isWin ? "oam.exe" : "oam";
83
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
+
84
134
  /**
85
- * Locate an oam binary. Returns `{ path, shim }`:
86
- * path -- an oam this launcher can actually execute, or null
87
- * 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`.
88
138
  *
89
- * The shim is reported rather than silently dropped: "no oam binary was found"
90
- * is the wrong thing to tell someone who has one installed in a shape we cannot
91
- * spawn. Every branch is a stat, never a subprocess.
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.
146
+ *
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.
92
153
  */
93
- function findOam() {
94
- // 1. Explicit override wins and is never second-guessed -- including a .cmd.
95
- // If it cannot be executed the version gate reports that specifically,
96
- // which is better than second-guessing an explicit instruction here.
97
- const override = process.env.OAM_BIN;
98
- if (override) return { path: existsSync(override) ? override : null, shim: null };
99
-
100
- // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
101
- // usually has oam/target/release on PATH, and a build directory is the
102
- // wrong thing for a user-facing launcher to bind to: cargo replaces the
103
- // binary underneath running processes, and the dev build is not the
104
- // release the user installed. Preferring the installed copy makes the
105
- // default path "what a normal user has", and OAM_BIN remains the way to
106
- // point deliberately at a dev build.
107
- //
108
- // Both forms are checked on Windows: the installer defaults to
109
- // %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
110
- // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
111
- // install.
154
+ function discoverOamPaths() {
112
155
  const installed = [join(homedir(), ".oam", "bin", exe)];
113
156
  if (isWin) {
114
157
  installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
115
158
  }
116
- for (const candidate of installed) {
117
- if (existsSync(candidate)) return { path: candidate, shim: null };
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);
118
171
  }
119
-
120
- // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
121
- // would cost a subprocess on every launch just to decide whether to spawn.
122
- //
123
- // Windows: only `.exe` is RETURNED -- deliberately narrower than PATHEXT.
124
- // Node refuses to run a `.cmd`/`.bat` through execFile/spawn without
125
- // `shell: true` (EINVAL, and for spawn it throws SYNCHRONOUSLY rather than
126
- // emitting 'error'), so returning one would hand back a path this launcher
127
- // cannot execute -- discovery has to agree with execution. `exe` is also
128
- // what the installed-location checks above look for, so both discovery
129
- // paths accept exactly the same shapes.
130
- //
131
- // A shim is still NOTED, though. An npm-style install puts `oam.cmd` on
132
- // PATH, and staying silent about it means auto mode degrades with no
133
- // explanation and `SSH_MCP_RUNTIME=oam` claims nothing was found -- both
134
- // of which send someone to reinstall an oam they already have.
135
- let shim = null;
136
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
137
- if (!dir) continue;
138
- const candidate = join(dir, exe);
139
- if (existsSync(candidate)) return { path: candidate, shim: null };
140
- if (isWin && shim === null) {
141
- for (const ext of [".cmd", ".bat"]) {
142
- const alt = join(dir, `oam${ext}`);
143
- if (existsSync(alt)) {
144
- shim = alt;
145
- break;
146
- }
147
- }
148
- }
149
- }
150
-
151
- return { path: null, shim };
172
+ return found;
152
173
  }
153
174
 
154
175
  /**
@@ -195,10 +216,12 @@ function oamVersion(cmd) {
195
216
  const out = execFileSync(cmd, ["--version"], {
196
217
  encoding: "utf-8",
197
218
  stdio: ["ignore", "pipe", "ignore"],
219
+ timeout: VERSION_PROBE_TIMEOUT_MS,
220
+ windowsHide: true,
198
221
  });
199
222
  return parseVersion(out);
200
223
  } catch {
201
- // Not executable, wrong arch, or deleted since the stat. Caller degrades.
224
+ // Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
202
225
  return null;
203
226
  }
204
227
  }
@@ -213,27 +236,148 @@ function atLeast(v, min) {
213
236
  return true;
214
237
  }
215
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
+
216
255
  /**
217
256
  * Where the server runs, decided BEFORE any discovery:
218
- * "in-process" import it into THIS process
219
- * "discover" find an oam binary, gate its version, spawn it -- or fall
220
- * back to Node in-process when that fails
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
221
261
  *
222
- * `hostOam` is `process.versions.oam`: oam's own key, absent on Node, so on
223
- * Node every mode but `node` is the discovery path it always was. The floor is
224
- * OAM_MIN itself, not a parameter, so a host oam and a discovered one can never
225
- * be held to different minimums. There is no sandbox input because this
226
- * launcher has no sandbox; see ALREADY RUNNING ON OAM above for why nothing
227
- * else forces a spawn.
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.
228
270
  *
229
271
  * Pure on purpose: every input is passed in, so the whole decision is testable
230
272
  * without booting a runtime.
231
273
  */
232
274
  function runtimePlan({ mode, hostOam }) {
233
- if (mode === "node") return "in-process";
275
+ const onOam = hostOam !== undefined;
276
+ if (mode === "node") return onOam ? "handoff-node" : "in-process";
234
277
  return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
235
278
  }
236
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
+
237
381
  /** Run the server in THIS process. The zero-overhead fallback. */
238
382
  async function runInProcess() {
239
383
  // A server may gate its bootstrap on being the process ENTRY POINT --
@@ -250,207 +394,253 @@ async function runInProcess() {
250
394
  await import(SERVER_URL.href);
251
395
  }
252
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
+
253
590
  const mode = (process.env.SSH_MCP_RUNTIME ?? "auto").toLowerCase();
254
- const plan = runtimePlan({ mode, hostOam: process.versions.oam });
591
+ const hostOam = process.versions.oam;
592
+ const plan = runtimePlan({ mode, hostOam });
255
593
 
256
594
  if (plan === "in-process") {
257
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(".")}` : "");
258
599
  } else {
259
- const { path: oam, shim: oamShim } = findOam();
260
- // Read the version ONCE, and only when discovery found something: the gate
261
- // below has to tell "too old" apart from "could not be read at all", and
262
- // re-probing inside the branch would cost a second subprocess.
263
- //
264
- // Discovery itself stays stat-only; this is the first subprocess. It is paid
265
- // on every launch that finds an oam -- including the ones that go on to fall
266
- // back to Node -- not only the ones that end up spawning it. Measured 26ms
267
- // median (n=12, windows-arm64), once per MCP session.
268
- const found = oam ? oamVersion(oam) : null;
600
+ const { chosen, overrideNote, skipped, passedOver, overrideMissing } = chooseOam();
269
601
 
270
- if (!oam) {
271
- // An oam-named .cmd/.bat on PATH is a real install in a shape this launcher
272
- // cannot spawn. Naming it turns "no oam binary was found" -- which reads as
273
- // "install oam", the one thing that will not help -- into something the user
274
- // can act on.
275
- const shimNote = oamShim
276
- ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
277
- "Install the native oam binary, or point OAM_BIN at one.\n"
278
- : "";
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
+ ];
279
626
  if (mode === "oam") {
280
627
  // Explicitly demanded, so this is a real misconfiguration.
281
628
  await errSync(
282
- `ssh-mcp: SSH_MCP_RUNTIME=oam but no runnable oam binary was found.\n${shimNote}` +
283
- "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 }),
284
632
  );
285
633
  process.exit(1);
286
634
  }
287
635
  // auto: falling back is correct, but silence is how someone never learns
288
- // their oam install is a shape this launcher skips. Only worth saying when
289
- // there was actually something to skip.
290
- if (oamShim) await errSync(`ssh-mcp: ${shimNote}Using Node instead.\n`);
291
- await runInProcess();
292
- } else if (!atLeast(found, OAM_MIN)) {
293
- const min = OAM_MIN.join(".");
294
- // Two different causes reach this branch and they need different remedies.
295
- // `found === null` is NOT "old": oamVersion returns null when the binary
296
- // could not be run at all (not executable, wrong arch, a .cmd/.bat Node
297
- // refuses, deleted between the stat and the probe) or when its --version
298
- // output did not parse. Telling that user to `oam self-update` sends them
299
- // after the one cause it definitely is not, so the wording splits here.
300
- const detail = found
301
- ? `${oam} is oam ${found.join(".")}, older than ${min}`
302
- : `${oam} could not be run, or did not report a version this launcher understands`;
303
- const remedy = found
304
- ? "Run `oam self-update`, or use SSH_MCP_RUNTIME=node.\n"
305
- : "Check that it is an executable oam binary for this platform, or use SSH_MCP_RUNTIME=node.\n";
306
- if (mode === "oam") {
307
- await errSync(`ssh-mcp: SSH_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
308
- process.exit(1);
309
- }
310
- // auto: neither cause is worth failing over -- prefer Node. Say so, because
311
- // a silent downgrade is how someone keeps running an oam they meant to
312
- // update, or never learns their oam is unexecutable. stdout carries the MCP
313
- // 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.
314
638
  //
315
639
  // errSync, not process.stderr.write: an exit DOES follow, just indirectly.
316
640
  // runInProcess() imports dist/index.js, whose top level answers `--version`
317
641
  // with console.log + process.exit(0) (src/index.ts) -- and that exit
318
642
  // truncates a pending async stderr write on Windows TTYs and pipes.
319
- await errSync(`ssh-mcp: ${detail}; using Node instead.\n`);
320
- await runInProcess();
321
- } else {
322
- // Every "oam could not be executed" outcome lands here: the synchronous
323
- // throw from spawn() and the async 'error' event both mean the same thing
324
- // and must degrade the same way, so the handling lives in one place.
325
- // errSync rather than process.stderr.write because stderr is async for
326
- // TTYs and pipes on Windows and the process.exit below truncates pending
327
- // writes -- the same reason the two branches above use it.
328
- const launchFailed = async (err) => {
329
- if (mode === "oam") {
330
- await errSync(`ssh-mcp: failed to launch oam (${err?.message ?? err})\n`);
331
- process.exit(1);
332
- }
333
- await runInProcess();
334
- };
335
-
336
- // ONE reporter shared by both launchFailed call sites below, so the
337
- // sync-throw path and the 'error'-event path cannot drift apart. Either can
338
- // reject: in auto mode launchFailed awaits runInProcess(), a bare import()
339
- // that rejects whenever dist/index.js is missing or throws at load. At ESM
340
- // top level an unhandled rejection is an uncaught exception -- it kills the
341
- // process and replaces this launcher's diagnostic with a raw stack trace,
342
- // which is the exact failure this handling exists to prevent.
343
- const fallbackFailed = (e) => {
344
- process.stderr.write(`ssh-mcp: fallback to Node failed (${e?.message ?? e})\n`);
345
- process.exitCode = 1;
346
- };
347
-
348
- // `--` separates oam's own flags from the script's argv, so `ssh-mcp
349
- // --version` and any host-supplied flags survive the hop unchanged.
350
- let child = null;
351
- try {
352
- child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
353
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
354
- // stdin/stdout is untouched and the host's stdin-close still reaches the
355
- // server's shutdown path.
356
- stdio: "inherit",
357
- env: process.env,
358
- windowsHide: true,
359
- });
360
- } catch (err) {
361
- // spawn() THROWS for some failures instead of emitting 'error', and the
362
- // 'error' listener is registered AFTER this call, so it can never observe
363
- // one -- an uncaught throw here kills the launcher with a raw stack trace
364
- // instead of falling back to Node.
365
- //
366
- // Belt-and-braces, deliberately: reaching this line already means
367
- // execFileSync ran this same binary and read a version from it, so the
368
- // shapes that throw synchronously (a .cmd/.bat Node refuses with EINVAL)
369
- // have been diverted by the version gate above, and the ones the comments
370
- // below name -- deleted (ENOENT), permission (EACCES) -- are among the
371
- // errnos Node routes to the async 'error' event instead. What is left is
372
- // a genuine TOCTOU: the binary replaced between the probe and the spawn.
373
- // Cheap to keep, and the alternative is a stack trace in a stdio server.
374
- await launchFailed(err).catch(fallbackFailed);
375
- }
376
-
377
- if (child) {
378
- // If oam cannot be executed at all (deleted between the stat and the spawn,
379
- // wrong arch, permission), fall back rather than failing the whole server.
380
- // `spawned` prevents falling back AFTER the child started, which would
381
- // double-start the server on the same stdio.
382
- let spawned = false;
383
- child.on("spawn", () => {
384
- spawned = true;
385
- });
386
- child.on("error", (err) => {
387
- if (spawned) return;
388
- // Handle the rejection instead of discarding the promise: a failing
389
- // runInProcess() used to escape as an unhandled rejection, replacing
390
- // this launcher's diagnostic with a raw stack trace.
391
- launchFailed(err).catch(fallbackFailed);
392
- });
393
-
394
- // Forward termination so the server's own shutdown path runs in the child
395
- // rather than the child being orphaned.
396
- //
397
- // Registering ANY handler for these suppresses Node's default
398
- // terminate-on-signal, so the parent's exit has to be arranged
399
- // explicitly. `child.killed` only records that kill() was CALLED, never
400
- // that the child is gone, so gating on it swallows every signal after the
401
- // first and wedges the launcher with no escape hatch.
402
- //
403
- // Escalation is driven by a TIMER, not by counting signals, and not by
404
- // comparing timestamps. Counting is ambiguous: a supervisor routinely
405
- // sends SIGINT then SIGTERM milliseconds apart, and a terminal Ctrl-C
406
- // reaches the whole process group, so the child usually gets its own copy
407
- // alongside ours -- reading "a second signal" as impatience hard-kills a
408
- // child that is already shutting down cleanly. A timer makes the count
409
- // irrelevant: ONE press is enough, and a wedged child dies on schedule
410
- // without the user having to guess how many times to press. It also
411
- // sidesteps the wall clock -- setTimeout is monotonic, so a clock step
412
- // cannot mis-gate the window in either direction.
413
- //
414
- // POSIX vs Windows, and why we do not forward on Windows.
415
- // On POSIX child.kill(sig) delivers a real, catchable signal, so
416
- // forwarding is what lets the child run its shutdown. On Windows there
417
- // are no POSIX signals: child.kill IGNORES the name and calls
418
- // TerminateProcess -- an immediate hard kill (verified: a child with a
419
- // SIGTERM handler never runs it and dies with code=null). Forwarding
420
- // there would ABORT the graceful shutdown the console's own Ctrl-C just
421
- // started, skipping the child's process.on("exit") backstop -- which is
422
- // what reaps an ssh-agent this server spawned (killStartedAgent,
423
- // src/env.ts) -- and leak the daemon. The console has already notified
424
- // the child, so on Windows the timer below is the only kill we issue.
425
- //
426
- // The window comfortably exceeds the child's own shutdown budget
427
- // (server.close -> pool.drain -> killStartedAgent -> ~100ms FIN grace).
428
- const ESCALATE_AFTER_MS = 2000;
429
- let escalation = null;
430
- for (const sig of ["SIGINT", "SIGTERM"]) {
431
- process.on(sig, () => {
432
- // No try/catch: kill() on an already-exited child returns false, it
433
- // does not throw. It throws only for a signal the platform does not
434
- // know, which SIGINT/SIGTERM/SIGKILL never are.
435
- if (!isWin) child.kill(sig);
436
- if (escalation) return; // already counting down; further signals are noise
437
- escalation = setTimeout(() => {
438
- // Still here after its grace window. Stop waiting on it.
439
- child.kill("SIGKILL");
440
- process.exit(128 + (constants.signals[sig] ?? 15));
441
- }, ESCALATE_AFTER_MS);
442
- });
443
- }
444
-
445
- child.on("exit", (code, signal) => {
446
- if (escalation) clearTimeout(escalation);
447
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
448
- // conventional shell exit status rather than a bare 0.
449
- if (signal) {
450
- process.exit(128 + (constants.signals[signal] ?? 15));
451
- }
452
- process.exit(code ?? 0);
453
- });
454
- }
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);
455
645
  }
456
646
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.15.2",
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",