@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.
- package/README.md +39 -0
- package/bin/ssh-mcp.mjs +510 -270
- 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
|
|
6
|
-
*
|
|
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.
|
|
13
|
-
* stat-only
|
|
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
|
|
17
|
-
*
|
|
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.
|
|
20
|
-
*
|
|
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.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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=
|
|
44
|
-
* SSH_MCP_RUNTIME=
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* spawn
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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 (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
233
|
-
|
|
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
|
|
239
|
-
//
|
|
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: ${
|
|
270
|
-
await
|
|
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.
|
|
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",
|