ssh-mcp 2.3.2 → 2.3.4
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 +1 -1
- package/build/cli.js +221 -0
- package/build/config/sddl.js +453 -0
- package/build/config/windows-acl.js +1 -423
- package/build/index.js +38 -220
- package/build/ssh/channel-signal.js +193 -0
- package/build/ssh/connection-registry.js +4 -3
- package/build/ssh/connection.js +64 -45
- package/build/ssh/session-manager.js +33 -12
- package/build/ssh/session.js +28 -4
- package/build/tools/descriptions.js +1 -1
- package/build/tools/session-tools.js +58 -4
- package/package.json +3 -2
package/build/index.js
CHANGED
|
@@ -1,222 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import {
|
|
5
|
-
import { OperatorError, ConfigNotFoundError, reportFatal } from './errors.js';
|
|
4
|
+
import { reportFatal } from './errors.js';
|
|
6
5
|
import { ConnectionRegistry } from './ssh/connection-registry.js';
|
|
7
|
-
import { PolicyEngine,
|
|
6
|
+
import { PolicyEngine, resolvePolicyRules } from './policy/engine.js';
|
|
8
7
|
import { AuditStore } from './audit/store.js';
|
|
9
8
|
import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
|
|
10
9
|
import { initKeychain } from './config/credential-resolver.js';
|
|
11
10
|
import { SERVER_VERSION } from './version.js';
|
|
12
|
-
|
|
13
|
-
const args = process.argv.slice(2);
|
|
14
|
-
const config = {};
|
|
15
|
-
for (const arg of args) {
|
|
16
|
-
if (arg.startsWith('--')) {
|
|
17
|
-
const eq = arg.indexOf('=');
|
|
18
|
-
if (eq === -1) {
|
|
19
|
-
config[arg.slice(2)] = null;
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
config[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return config;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Whether a boolean flag was passed at all.
|
|
30
|
-
*
|
|
31
|
-
* `parseArgv` stores `null` for a flag written without `=`, which is how every
|
|
32
|
-
* documented boolean flag is written. Testing that value for truthiness makes
|
|
33
|
-
* the flag a no-op: `--disableApproval`, `--auditEntropyScan` and
|
|
34
|
-
* `--auditTamperEvident` all did nothing unless spelled `--flag=1`, which
|
|
35
|
-
* nothing documents (#91). The two call sites that got it right already used
|
|
36
|
-
* `!== undefined`; this gives the check a name so the next one cannot miss it.
|
|
37
|
-
*
|
|
38
|
-
* `--flag=false` and `--flag=0` turn it off, because a flag that cannot be
|
|
39
|
-
* turned off once written into a wrapper script is its own annoyance.
|
|
40
|
-
*/
|
|
41
|
-
function flagEnabled(argv, name) {
|
|
42
|
-
if (!(name in argv))
|
|
43
|
-
return false;
|
|
44
|
-
const value = argv[name];
|
|
45
|
-
if (value === null || value === '')
|
|
46
|
-
return true;
|
|
47
|
-
return !['false', '0', 'no', 'off'].includes(value.toLowerCase());
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* v1 flags that v2 removed. Silently ignoring them means the user only finds
|
|
51
|
-
* out at the first command, as an auth failure with no hint about the cause.
|
|
52
|
-
*/
|
|
53
|
-
const REMOVED_V1_FLAGS = {
|
|
54
|
-
password: 'Use the SSH_MCP_PASSWORD env var (or a per-profile SSH_MCP_<PROFILE>_PASSWORD).',
|
|
55
|
-
suPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
|
|
56
|
-
sudoPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
|
|
57
|
-
disableSudo: 'Sudo is now a separate tool; restrict it with a role/policy that disallows the "privileged" class.',
|
|
58
|
-
};
|
|
59
|
-
function checkRemovedFlags(argv) {
|
|
60
|
-
const found = Object.keys(REMOVED_V1_FLAGS).filter((f) => f in argv);
|
|
61
|
-
if (found.length === 0)
|
|
62
|
-
return;
|
|
63
|
-
throw new OperatorError('These flags were removed in v2:\n' +
|
|
64
|
-
found.map((f) => ` --${f}: ${REMOVED_V1_FLAGS[f]}`).join('\n') +
|
|
65
|
-
'\nSee the "Migrating from v1" section of the README.');
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* v1 documented `--maxChars=none` (and 0/negative) as "no limit". Parsing it
|
|
69
|
-
* with `parseInt(...) || 5000` silently turned that into a 5000-char cap.
|
|
70
|
-
*/
|
|
71
|
-
function parseMaxChars(raw) {
|
|
72
|
-
if (typeof raw !== 'string' || raw === '')
|
|
73
|
-
return 5_000;
|
|
74
|
-
if (raw.toLowerCase() === 'none')
|
|
75
|
-
return Number.MAX_SAFE_INTEGER;
|
|
76
|
-
const parsed = parseInt(raw);
|
|
77
|
-
if (isNaN(parsed))
|
|
78
|
-
return 5_000;
|
|
79
|
-
return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* `strict` was previously unreachable: nothing but test code could select it,
|
|
83
|
-
* yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
|
|
84
|
-
* existed. `--insecureHostKey` is kept as an alias for the documented flag.
|
|
85
|
-
*/
|
|
86
|
-
function resolveHostKeyMode(argv) {
|
|
87
|
-
if (flagEnabled(argv, 'insecureHostKey'))
|
|
88
|
-
return 'insecure';
|
|
89
|
-
const mode = argv.hostKeyMode;
|
|
90
|
-
if (mode === null || mode === undefined)
|
|
91
|
-
return 'tofu';
|
|
92
|
-
if (mode === 'tofu' || mode === 'strict' || mode === 'insecure')
|
|
93
|
-
return mode;
|
|
94
|
-
throw new OperatorError(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Which policy tier a CLI-configured host belongs to.
|
|
98
|
-
*
|
|
99
|
-
* Without this there was no way to say it from the command line at all. The
|
|
100
|
-
* inline profile carries no group, so it fell to the strictest tier, where the
|
|
101
|
-
* `admin` role has no `privileged` — meaning `sudo` could never run for anyone
|
|
102
|
-
* who had not written a config file (#91).
|
|
103
|
-
*
|
|
104
|
-
* The default stays `prod`. Guessing an unknown host is production is the safe
|
|
105
|
-
* direction; what was missing was a way to correct the guess.
|
|
106
|
-
*/
|
|
107
|
-
export function resolveHostGroup(argv) {
|
|
108
|
-
const group = argv.group;
|
|
109
|
-
if (group === null || group === undefined)
|
|
110
|
-
return 'prod';
|
|
111
|
-
if (HOST_GROUPS.includes(group))
|
|
112
|
-
return group;
|
|
113
|
-
// Falling through would silently apply the prod bindings to a typo, and the
|
|
114
|
-
// operator would read the refusal as policy rather than as their own slip.
|
|
115
|
-
throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Every ACL finding, in the order it happened — both "readable beyond its owner" and
|
|
119
|
-
* "could not be checked".
|
|
120
|
-
*
|
|
121
|
-
* Collected rather than only printed so `main()` can act on them once there is somewhere
|
|
122
|
-
* durable to put them; see the note at the sink below.
|
|
123
|
-
*/
|
|
124
|
-
const aclFindings = [];
|
|
125
|
-
async function buildAppConfig(argv) {
|
|
126
|
-
// The way past an ACL that could not be read, rather than an operator with no
|
|
127
|
-
// exit. See assertPrivateOnWindows.
|
|
128
|
-
// The sink is supplied here rather than left to the module's default, which is the
|
|
129
|
-
// point of having one: "this security check did not run" is a decision about what the
|
|
130
|
-
// operator must be told, and the loader is not the layer that owns that. Routing it to
|
|
131
|
-
// the audit store as well needs an AuditRecord variant for a startup event — that type
|
|
132
|
-
// is hash-chained and tamper-evident, so it is its own change, and it is queued.
|
|
133
|
-
const aclOpts = {
|
|
134
|
-
enforce: flagEnabled(argv, 'strictConfigAcl'),
|
|
135
|
-
allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
|
|
136
|
-
onFinding: (f) => {
|
|
137
|
-
aclFindings.push(f);
|
|
138
|
-
console.error(`Warning: ${f.message}`);
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
if (argv.config !== undefined) {
|
|
142
|
-
// `parseArgv` stores null for a flag written without `=`, so a bare `--config` used to
|
|
143
|
-
// be falsy here and fall into the default-path branch — which now refuses rather than
|
|
144
|
-
// silently ignoring an unusable file, so the operator got a refusal naming a path they
|
|
145
|
-
// never asked for.
|
|
146
|
-
if (argv.config === null || argv.config === '') {
|
|
147
|
-
throw new OperatorError('--config needs a path: --config=<path>');
|
|
148
|
-
}
|
|
149
|
-
return loadConfig(argv.config, aclOpts);
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
const fromFile = await loadConfig(undefined, aclOpts);
|
|
153
|
-
// Before #138 every Windows config was rejected by the mode-bit check and
|
|
154
|
-
// the rejection was swallowed, so an operator who also passed --host/--user
|
|
155
|
-
// was silently running off the flags. Now that the file loads, it takes
|
|
156
|
-
// precedence — a flip worth one line rather than a surprise about which host
|
|
157
|
-
// a command reached.
|
|
158
|
-
if (argv.host || argv.user) {
|
|
159
|
-
console.error(`Note: ${getConfigPath()} was loaded, so --host/--user are ignored. ` +
|
|
160
|
-
'Pass --config <path> to choose a different file.');
|
|
161
|
-
}
|
|
162
|
-
return fromFile;
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
// Only "there is no file" means fall through. A file that exists and is
|
|
166
|
-
// unusable — malformed TOML, a schema violation, a permission failure —
|
|
167
|
-
// used to be swallowed here too and reported as a missing config, which is
|
|
168
|
-
// how #138 stayed unfindable: the operator's file was in the right place,
|
|
169
|
-
// and the real error was discarded before anyone could read it.
|
|
170
|
-
//
|
|
171
|
-
// Falling through would also be wrong on its own terms whenever
|
|
172
|
-
// --host/--user happen to be present: the server would start, silently
|
|
173
|
-
// ignoring every profile, policy and role binding the operator wrote.
|
|
174
|
-
if (!(err instanceof ConfigNotFoundError))
|
|
175
|
-
throw err;
|
|
176
|
-
}
|
|
177
|
-
if (!argv.host || !argv.user) {
|
|
178
|
-
throw new OperatorError('No config file found and missing required --host/--user.\n' +
|
|
179
|
-
// Was hardcoded to ~/.config/ssh-mcp/config.toml on every platform, so on
|
|
180
|
-
// Windows and macOS it named a path this code never reads.
|
|
181
|
-
`Either create a config file at ${getConfigPath()} or pass --config <path>.\n` +
|
|
182
|
-
'For quick start: --host=<host> --user=<user> (credentials via env vars).');
|
|
183
|
-
}
|
|
184
|
-
const defaults = {
|
|
185
|
-
sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
|
|
186
|
-
sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
|
|
187
|
-
sessionBackgroundMaxMs: 3_600_000,
|
|
188
|
-
commandTimeoutMs: parseInt(argv.timeout) || 60_000,
|
|
189
|
-
commandMaxChars: parseMaxChars(argv.maxChars),
|
|
190
|
-
commandMaxOutputBytes: 1_048_576,
|
|
191
|
-
connectionIdleReapMs: 900_000,
|
|
192
|
-
commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
|
|
193
|
-
approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
|
|
194
|
-
approvalMode: 'ask-destructive',
|
|
195
|
-
};
|
|
196
|
-
const profile = {
|
|
197
|
-
name: 'default',
|
|
198
|
-
host: argv.host,
|
|
199
|
-
port: parseInt(argv.port) || 22,
|
|
200
|
-
user: argv.user,
|
|
201
|
-
auth: argv.key ? 'key' : 'password',
|
|
202
|
-
keyRef: argv.key || undefined,
|
|
203
|
-
workdir: argv.workdir || undefined,
|
|
204
|
-
tty: false,
|
|
205
|
-
timeout: defaults.commandTimeoutMs,
|
|
206
|
-
maxChars: defaults.commandMaxChars,
|
|
207
|
-
maxOutputBytes: defaults.commandMaxOutputBytes,
|
|
208
|
-
role: 'admin',
|
|
209
|
-
group: resolveHostGroup(argv),
|
|
210
|
-
readOnly: false,
|
|
211
|
-
approvalPolicy: flagEnabled(argv, 'disableApproval') ? 'auto' : defaults.approvalMode,
|
|
212
|
-
cert: false,
|
|
213
|
-
sessionMaxPerConnection: defaults.sessionMaxPerConnection,
|
|
214
|
-
sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
|
|
215
|
-
sessionBackgroundMaxMs: defaults.sessionBackgroundMaxMs,
|
|
216
|
-
commandQuotaPerDay: defaults.commandQuotaPerDay,
|
|
217
|
-
};
|
|
218
|
-
return { defaults, profiles: [profile] };
|
|
219
|
-
}
|
|
11
|
+
import { parseArgv, buildAppConfig, checkRemovedFlags, flagEnabled, resolveHostKeyMode, } from './cli.js';
|
|
220
12
|
async function main() {
|
|
221
13
|
const argv = parseArgv();
|
|
222
14
|
checkRemovedFlags(argv);
|
|
@@ -268,14 +60,29 @@ async function main() {
|
|
|
268
60
|
console.error('SSH MCP Server v2 running on stdio');
|
|
269
61
|
}
|
|
270
62
|
const reaperInterval = setInterval(() => {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
63
|
+
// Session reaping is awaited before connection reaping, and that order is load-bearing:
|
|
64
|
+
// closing a background session now signals its command, and `reapIdleConnections` gates
|
|
65
|
+
// on `sessionCount`, which drops to zero as soon as a session is closed. Fired without
|
|
66
|
+
// awaiting, the connection was torn down microseconds after the first signal and the
|
|
67
|
+
// TERM and KILL rungs were discarded — so a command that ignores INT survived its own
|
|
68
|
+
// reaping.
|
|
69
|
+
void (async () => {
|
|
70
|
+
const conns = registry.listConnections()
|
|
71
|
+
.map((info) => registry.get(info.profile))
|
|
72
|
+
.filter((c) => Boolean(c));
|
|
73
|
+
await Promise.all(conns.map((conn) => conn.reapExpiredSessions().catch(() => { })));
|
|
74
|
+
registry.reapIdleConnections();
|
|
75
|
+
})();
|
|
278
76
|
}, 60_000);
|
|
77
|
+
/**
|
|
78
|
+
* How long teardown gets before the process exits regardless.
|
|
79
|
+
*
|
|
80
|
+
* One kill ladder (3s) plus slack. Sessions and connections close concurrently, so this
|
|
81
|
+
* is the whole budget rather than a per-session one. Docker's default stop grace is 10s
|
|
82
|
+
* and Kubernetes' is 30s, so this stays comfortably inside both — the point is that we
|
|
83
|
+
* choose the moment we give up rather than being SIGKILLed in the middle of it.
|
|
84
|
+
*/
|
|
85
|
+
const SHUTDOWN_BUDGET_MS = 5000;
|
|
279
86
|
let isShuttingDown = false;
|
|
280
87
|
const cleanup = async () => {
|
|
281
88
|
if (isShuttingDown)
|
|
@@ -283,14 +90,26 @@ async function main() {
|
|
|
283
90
|
isShuttingDown = true;
|
|
284
91
|
console.error('Shutting down SSH MCP Server...');
|
|
285
92
|
clearInterval(reaperInterval);
|
|
93
|
+
// Audit first. An earlier version of this comment claimed session teardown wrote audit
|
|
94
|
+
// records that the old order dropped — it does not: nothing under src/ssh/ touches the
|
|
95
|
+
// audit store, only the `close-session` tool does, and that record is written and
|
|
96
|
+
// awaited during the tool call. So the reorder rescued nothing and put the final flush
|
|
97
|
+
// behind a teardown that now waits for kill ladders. Flushing first cannot lose a record
|
|
98
|
+
// that teardown does not write, and it survives a SIGKILL at the end of the grace period.
|
|
286
99
|
try {
|
|
287
100
|
await audit.close();
|
|
288
101
|
}
|
|
289
102
|
catch (e) {
|
|
290
103
|
console.error('audit.close failed:', e);
|
|
291
104
|
}
|
|
105
|
+
// Bounded, so `process.exit(0)` is reached deliberately rather than by the container
|
|
106
|
+
// runtime's SIGKILL. Teardown is concurrent now, so the honest ceiling is one kill
|
|
107
|
+
// ladder plus slack rather than one per session.
|
|
292
108
|
try {
|
|
293
|
-
await
|
|
109
|
+
await Promise.race([
|
|
110
|
+
registry.closeAll(),
|
|
111
|
+
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_BUDGET_MS).unref()),
|
|
112
|
+
]);
|
|
294
113
|
}
|
|
295
114
|
catch (e) {
|
|
296
115
|
console.error('closeAll failed:', e);
|
|
@@ -315,4 +134,3 @@ if (isCliEnabled || isTestMode) {
|
|
|
315
134
|
process.exit(reportFatal(error));
|
|
316
135
|
});
|
|
317
136
|
}
|
|
318
|
-
export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars, flagEnabled, resolveHostKeyMode };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** Outgoing states in which the far end still has a channel to receive a request. */
|
|
2
|
+
const OPEN_STATES = new Set(['open', 'eof']);
|
|
3
|
+
/**
|
|
4
|
+
* Whether ssh2's own `Channel.signal()` would put this request on the wire.
|
|
5
|
+
*
|
|
6
|
+
* A deliberate copy of lib/Channel.js:241-243 in ssh2 1.17, and the single place to
|
|
7
|
+
* update when that changes. Copying it is what lets us tell "ssh2 will handle this"
|
|
8
|
+
* from "ssh2 will silently discard it" — the distinction the whole module exists for.
|
|
9
|
+
*/
|
|
10
|
+
function ssh2WillSend(ch) {
|
|
11
|
+
return ch.type === 'session' && ch.writable === true && ch.outgoing?.state === 'open';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether the transport can still carry a packet.
|
|
15
|
+
*
|
|
16
|
+
* ssh2 hands every packet to `onWrite`, which is `if (isWritable(sock)) sock.write(data)`
|
|
17
|
+
* (lib/client.js:303) — an unwritable socket means the packet is dropped with no error
|
|
18
|
+
* and no return value. Measured against 1.17.0: immediately after `client.end()`,
|
|
19
|
+
* `sock.writable` is false while `outgoing.state` is still `'eof'`, so the state guard
|
|
20
|
+
* below passes and the signal call returns without throwing. Reporting that as delivery
|
|
21
|
+
* is #146 one layer down, and it is reachable whenever a connection is closed under a
|
|
22
|
+
* running command — `SSHConnection.close()`, or the idle reaper, which does not consult
|
|
23
|
+
* `activeChannels`.
|
|
24
|
+
*
|
|
25
|
+
* All three of `isWritable`'s conjuncts, not just the first. The first version of this
|
|
26
|
+
* checked `sock.writable` alone, and ssh2 added the other two for a reason
|
|
27
|
+
* (nodejs/node#36029: `writable` stays true after the stream has ended). Measured:
|
|
28
|
+
*
|
|
29
|
+
* allowHalfOpen=false, peer FIN -> writable=false, isWritable()=false (agree)
|
|
30
|
+
* allowHalfOpen=true, peer FIN -> writable=true, isWritable()=false (disagree)
|
|
31
|
+
*
|
|
32
|
+
* and a ProxyJump connection's transport is exactly the second case — `connectConfig.sock`
|
|
33
|
+
* is a `forwardOut` channel, and ssh2's channels default to `allowHalfOpen: true`
|
|
34
|
+
* (lib/Channel.js:90). So the one-conjunct version was blind on the path that has its own
|
|
35
|
+
* integration test. Mirroring the whole predicate is also what `ssh2WillSend` does, and
|
|
36
|
+
* the asymmetry between the two was the actual defect.
|
|
37
|
+
*/
|
|
38
|
+
function onTheWire(ch) {
|
|
39
|
+
const sock = ch._client?._sock;
|
|
40
|
+
// Fail closed on a shape we cannot read: every other unknown in this module is reported
|
|
41
|
+
// as "not dispatched", and a guard whose job is to refuse when unsure must not be the
|
|
42
|
+
// one place that assumes the best.
|
|
43
|
+
if (sock === undefined || typeof sock.writable !== 'boolean')
|
|
44
|
+
return false;
|
|
45
|
+
return sock.writable && sock._readableState?.ended === false;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Send `name` to the process behind `channel`.
|
|
49
|
+
*
|
|
50
|
+
* Returns whether the request was **dispatched to the wire** — not whether the command
|
|
51
|
+
* stopped. Nothing in SSH acknowledges a signal request, the delivery semantics are the
|
|
52
|
+
* server's choice rather than the protocol's, and a target may refuse it outright, so
|
|
53
|
+
* "the process is gone" is not a fact this function can produce. What it can produce
|
|
54
|
+
* honestly is "I could not even ask", which is what the caller warns about, because a
|
|
55
|
+
* signal that goes nowhere while reporting success is the defect this module exists to
|
|
56
|
+
* remove.
|
|
57
|
+
*/
|
|
58
|
+
export function signalChannel(channel, name) {
|
|
59
|
+
const ch = channel;
|
|
60
|
+
const state = ch.outgoing?.state;
|
|
61
|
+
// Only session channels carry a process. ssh2 gates on this too, and a
|
|
62
|
+
// forwarded-tcpip channel has nothing to signal.
|
|
63
|
+
if (ch.type !== undefined && ch.type !== 'session')
|
|
64
|
+
return false;
|
|
65
|
+
// Already closing or closed: the request would have no channel to arrive on, and
|
|
66
|
+
// ssh2 may have handed the id to a new channel.
|
|
67
|
+
if (state !== undefined && !OPEN_STATES.has(state))
|
|
68
|
+
return false;
|
|
69
|
+
// Nothing can be dispatched through a socket ssh2 will not write to, and it will not
|
|
70
|
+
// tell us — so this has to be asked before either path claims success.
|
|
71
|
+
if (!onTheWire(ch))
|
|
72
|
+
return false;
|
|
73
|
+
// The supported path, taken whenever ssh2 will actually write the request.
|
|
74
|
+
if (ssh2WillSend(ch)) {
|
|
75
|
+
try {
|
|
76
|
+
channel.signal(name);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const id = ch.outgoing?.id;
|
|
84
|
+
const protocol = ch._client?._protocol;
|
|
85
|
+
if (typeof id === 'number' && typeof protocol?.signal === 'function') {
|
|
86
|
+
try {
|
|
87
|
+
protocol.signal(id, name);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A shape we do not recognise. Ask ssh2 and report failure anyway: we cannot tell
|
|
95
|
+
// whether it sent anything, and claiming delivery we cannot verify is what made
|
|
96
|
+
// this bug invisible for so long. An unnecessary "may still be running" warning is
|
|
97
|
+
// the better way to be wrong.
|
|
98
|
+
try {
|
|
99
|
+
channel.signal(name);
|
|
100
|
+
}
|
|
101
|
+
catch { /* nothing left to try */ }
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Appended when a stop request could not even be dispatched, so nothing was asked of the
|
|
106
|
+
* host at all.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately narrower than "the command is still running", which is not knowable from
|
|
109
|
+
* here: nothing acknowledges a signal request. Its *absence* therefore means "we asked",
|
|
110
|
+
* not "it died".
|
|
111
|
+
*
|
|
112
|
+
* It lives beside the function that decides it because two callers now need the same
|
|
113
|
+
* sentence — a timed-out or cancelled `exec`, and `close-session` on a background
|
|
114
|
+
* session. One contract, one place.
|
|
115
|
+
*/
|
|
116
|
+
export const COULD_NOT_SIGNAL = ' The remote command could not be signalled, so it may still be running on the host.';
|
|
117
|
+
/** How long each signal is given to work before the next one is tried. */
|
|
118
|
+
const ESCALATION_MS = 1000;
|
|
119
|
+
/**
|
|
120
|
+
* Escalate until the command is gone: INT, then TERM, then KILL, then drop the
|
|
121
|
+
* channel.
|
|
122
|
+
*
|
|
123
|
+
* KILL is last because it denies the command any chance to clean up; it exists
|
|
124
|
+
* because the rung below it used to be `close()`, and closing a channel was measured
|
|
125
|
+
* not to stop anything. A command that ignores INT and TERM used to run forever.
|
|
126
|
+
*
|
|
127
|
+
* Returns whether the *first* signal was dispatched — known synchronously, so the
|
|
128
|
+
* caller can say "I could not even ask" in the same breath as the timeout it is already
|
|
129
|
+
* reporting. It is not a claim that the command stopped, and callers must not read it
|
|
130
|
+
* as one: the later rungs have not run yet, and no rung is acknowledged by anything.
|
|
131
|
+
*/
|
|
132
|
+
export function terminateChannel(channel) {
|
|
133
|
+
const dispatched = signalChannel(channel, 'INT');
|
|
134
|
+
const pending = [];
|
|
135
|
+
const later = (fn, ms) => {
|
|
136
|
+
// Unreferenced: this ladder outlives the promise it settled by up to three
|
|
137
|
+
// seconds, and a referenced timer would add that to the exit of any process
|
|
138
|
+
// whose last act was a timed-out command.
|
|
139
|
+
pending.push(setTimeout(fn, ms).unref());
|
|
140
|
+
};
|
|
141
|
+
const stop = () => { for (const t of pending)
|
|
142
|
+
clearTimeout(t); pending.length = 0; };
|
|
143
|
+
// Nothing more to send once the channel is gone, and the id may be reused.
|
|
144
|
+
channel.once('close', stop);
|
|
145
|
+
later(() => { signalChannel(channel, 'TERM'); }, ESCALATION_MS);
|
|
146
|
+
later(() => { signalChannel(channel, 'KILL'); }, ESCALATION_MS * 2);
|
|
147
|
+
later(() => { try {
|
|
148
|
+
channel.close();
|
|
149
|
+
}
|
|
150
|
+
catch { /* already gone */ } }, ESCALATION_MS * 3);
|
|
151
|
+
return dispatched;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Wait for a channel to actually close, bounded.
|
|
155
|
+
*
|
|
156
|
+
* The ladder's rungs are timers, so `terminateChannel` returns long before the escalation
|
|
157
|
+
* has finished. Anything that tears the transport down afterwards — `SSHConnection.close()`
|
|
158
|
+
* calling `client.end()`, or the process exiting — cancels the rest of it, and a command
|
|
159
|
+
* that ignored INT then survives with no further escalation possible. Callers that are
|
|
160
|
+
* about to do that must wait here first.
|
|
161
|
+
*
|
|
162
|
+
* Resolves true if the channel closed, false on timeout. The default budget is the ladder
|
|
163
|
+
* plus a little, so a command that only dies to KILL still gets there.
|
|
164
|
+
*/
|
|
165
|
+
export function waitForChannelClose(channel, ms = ESCALATION_MS * 3 + 500) {
|
|
166
|
+
// ssh2 emits `'close'` exactly once, and `onCHANNEL_CLOSE` early-returns for a channel
|
|
167
|
+
// whose incoming state is already `'closed'` — so a listener attached after the fact
|
|
168
|
+
// never fires and the caller would burn the whole budget waiting for an event that has
|
|
169
|
+
// already happened. Not reachable through SessionManager today, because the session is
|
|
170
|
+
// removed from the map by that same emit; it is a trap for the next caller.
|
|
171
|
+
const incoming = channel.incoming;
|
|
172
|
+
if (incoming?.state === 'closed' || channel.destroyed)
|
|
173
|
+
return Promise.resolve(true);
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
let settled = false;
|
|
176
|
+
const onClose = () => done(true);
|
|
177
|
+
const done = (closed) => {
|
|
178
|
+
if (settled)
|
|
179
|
+
return;
|
|
180
|
+
settled = true;
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
// Detached on the timeout path too: a timed-out wait would otherwise leave its
|
|
183
|
+
// closure attached to a channel that may outlive it.
|
|
184
|
+
channel.removeListener('close', onClose);
|
|
185
|
+
resolve(closed);
|
|
186
|
+
};
|
|
187
|
+
const timer = setTimeout(() => done(false), ms);
|
|
188
|
+
// Unreferenced so a hung channel cannot hold the process open; the caller is awaiting
|
|
189
|
+
// this, so the loop stays alive for as long as the caller itself does.
|
|
190
|
+
timer.unref();
|
|
191
|
+
channel.once('close', onClose);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -94,9 +94,10 @@ export class ConnectionRegistry {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
async closeAll() {
|
|
97
|
-
for
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
// Concurrently, for the reason SessionManager.closeAll gives: each connection now waits
|
|
98
|
+
// for its sessions' kill ladders, and serialising multiplied that wait by the number of
|
|
99
|
+
// profiles.
|
|
100
|
+
await Promise.all([...this.connections.values()].map((conn) => conn.close().catch(() => { })));
|
|
100
101
|
this.connections.clear();
|
|
101
102
|
}
|
|
102
103
|
}
|
package/build/ssh/connection.js
CHANGED
|
@@ -6,6 +6,40 @@ import { tracer } from '../observability/tracer.js';
|
|
|
6
6
|
import { redactText } from '../guard/redactor.js';
|
|
7
7
|
import { shellSingleQuote } from '../guard/sanitizer.js';
|
|
8
8
|
import { openWithRetry } from './channel-retry.js';
|
|
9
|
+
import { terminateChannel, COULD_NOT_SIGNAL } from './channel-signal.js';
|
|
10
|
+
/**
|
|
11
|
+
* Stop the command behind `channel`, record why on the span, and return the sentence the
|
|
12
|
+
* rejection should carry.
|
|
13
|
+
*
|
|
14
|
+
* Module scope rather than a closure inside `exec`: it captures nothing but the span, and
|
|
15
|
+
* at module scope it is directly testable with a stub span and a fake channel. It exists
|
|
16
|
+
* at all because the three settle paths used to inline this, and two of them computed the
|
|
17
|
+
* "unstopped" value for the message and then dropped it before the span — so
|
|
18
|
+
* `ssh.unstopped` was structurally incapable of being true for a cancelled command, which
|
|
19
|
+
* is the most common way an MCP client stops one.
|
|
20
|
+
*/
|
|
21
|
+
export function stopAndDescribe(span, reason, channel) {
|
|
22
|
+
span.setAttribute(reason, true);
|
|
23
|
+
// A null channel means the exec callback has not arrived yet. It does NOT mean nothing is
|
|
24
|
+
// running — ssh2 invokes that callback on CHANNEL_SUCCESS, which OpenSSH sends after
|
|
25
|
+
// forking the command — but there is nothing to warn about either: `onExecChannel` stops
|
|
26
|
+
// such a late channel the moment it arrives. Warning here would put "may still be
|
|
27
|
+
// running" on every timeout that races a slow channel open, and a warning that fires when
|
|
28
|
+
// nothing is wrong stops being read.
|
|
29
|
+
//
|
|
30
|
+
// Recorded as its own attribute rather than as `ssh.unstopped: false`, which would assert
|
|
31
|
+
// a clean stop this path cannot yet know about. The late-arrival handler emits the real
|
|
32
|
+
// answer on its own span.
|
|
33
|
+
if (channel === null) {
|
|
34
|
+
span.setAttribute('ssh.stopDeferred', true);
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
const dispatched = terminateChannel(channel);
|
|
38
|
+
// Set on every settle path, always, so "stopped cleanly" and "ran an older build" are not
|
|
39
|
+
// the same absent-attribute query.
|
|
40
|
+
span.setAttribute('ssh.unstopped', !dispatched);
|
|
41
|
+
return dispatched ? '' : COULD_NOT_SIGNAL;
|
|
42
|
+
}
|
|
9
43
|
export class SSHConnection {
|
|
10
44
|
profile;
|
|
11
45
|
client = null;
|
|
@@ -193,25 +227,9 @@ export class SSHConnection {
|
|
|
193
227
|
const timeoutId = setTimeout(() => {
|
|
194
228
|
if (!resolved) {
|
|
195
229
|
resolved = true;
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
activeStream.signal('INT');
|
|
199
|
-
}
|
|
200
|
-
catch { /* */ }
|
|
201
|
-
setTimeout(() => {
|
|
202
|
-
try {
|
|
203
|
-
activeStream?.signal('TERM');
|
|
204
|
-
}
|
|
205
|
-
catch { /* */ }
|
|
206
|
-
setTimeout(() => { try {
|
|
207
|
-
activeStream?.close();
|
|
208
|
-
}
|
|
209
|
-
catch { /* */ } }, 1000);
|
|
210
|
-
}, 1000);
|
|
211
|
-
}
|
|
212
|
-
span.setAttribute('ssh.timedOut', true);
|
|
230
|
+
const note = stopAndDescribe(span, 'ssh.timedOut', activeStream);
|
|
213
231
|
span.end();
|
|
214
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
232
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms${note}`));
|
|
215
233
|
}
|
|
216
234
|
}, timeoutMs);
|
|
217
235
|
const execOpts = {};
|
|
@@ -240,6 +258,23 @@ export class SSHConnection {
|
|
|
240
258
|
}
|
|
241
259
|
return;
|
|
242
260
|
}
|
|
261
|
+
// The promise may already have settled — the timeout can fire while
|
|
262
|
+
// `openWithRetry` is still in flight, and ssh2 only invokes this callback once
|
|
263
|
+
// the server has replied CHANNEL_SUCCESS, which OpenSSH sends *after* forking
|
|
264
|
+
// the command. Without this the command ran to completion on the host after the
|
|
265
|
+
// caller had been told it timed out, holding a channel and discarding its
|
|
266
|
+
// output: #146 exactly, in the one path the first fix did not cover.
|
|
267
|
+
if (resolved) {
|
|
268
|
+
// On its own span: the exec span was ended when the promise settled, and an
|
|
269
|
+
// attribute set on an ended span is silently dropped. Without this the one
|
|
270
|
+
// outcome `stopAndDescribe` deferred would be unobservable — the shape this
|
|
271
|
+
// whole change exists to remove.
|
|
272
|
+
const late = tracer.startSpan('ssh.exec.lateStop');
|
|
273
|
+
late.setAttribute('ssh.host', this.profile.host);
|
|
274
|
+
late.setAttribute('ssh.unstopped', !terminateChannel(stream));
|
|
275
|
+
late.end();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
243
278
|
activeStream = stream;
|
|
244
279
|
this.activeChannels++;
|
|
245
280
|
let stdout = '';
|
|
@@ -261,42 +296,26 @@ export class SSHConnection {
|
|
|
261
296
|
if (opts.abortSignal.aborted) {
|
|
262
297
|
resolved = true;
|
|
263
298
|
clearTimeout(timeoutId);
|
|
299
|
+
// Decremented here rather than on 'close', because the close listener
|
|
300
|
+
// below is never attached on this path. The channel now outlives the
|
|
301
|
+
// count by as long as the kill ladder takes.
|
|
264
302
|
this.activeChannels--;
|
|
265
303
|
// client.exec() has already dispatched the command, so rejecting
|
|
266
304
|
// without signalling left it running to completion on the host and
|
|
267
|
-
// held a channel until it finished.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
catch { /* */ }
|
|
272
|
-
try {
|
|
273
|
-
stream.close();
|
|
274
|
-
}
|
|
275
|
-
catch { /* */ }
|
|
305
|
+
// held a channel until it finished. Closing the channel does not stop
|
|
306
|
+
// it either — only a delivered signal does (#146).
|
|
307
|
+
const note = stopAndDescribe(span, 'ssh.aborted', stream);
|
|
276
308
|
span.end();
|
|
277
|
-
reject(new Error(
|
|
309
|
+
reject(new Error(`Command aborted before execution${note}`));
|
|
278
310
|
return;
|
|
279
311
|
}
|
|
280
312
|
const onAbort = () => {
|
|
281
313
|
if (!resolved) {
|
|
282
314
|
resolved = true;
|
|
283
315
|
clearTimeout(timeoutId);
|
|
284
|
-
|
|
285
|
-
stream.signal('INT');
|
|
286
|
-
}
|
|
287
|
-
catch { /* */ }
|
|
288
|
-
setTimeout(() => {
|
|
289
|
-
try {
|
|
290
|
-
stream.signal('TERM');
|
|
291
|
-
}
|
|
292
|
-
catch { /* */ }
|
|
293
|
-
setTimeout(() => { try {
|
|
294
|
-
stream.close();
|
|
295
|
-
}
|
|
296
|
-
catch { /* */ } }, 1000);
|
|
297
|
-
}, 1000);
|
|
316
|
+
const note = stopAndDescribe(span, 'ssh.aborted', stream);
|
|
298
317
|
span.end();
|
|
299
|
-
reject(new Error(
|
|
318
|
+
reject(new Error(`Command aborted${note}`));
|
|
300
319
|
}
|
|
301
320
|
};
|
|
302
321
|
opts.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
@@ -359,8 +378,8 @@ export class SSHConnection {
|
|
|
359
378
|
async closeSession(name) {
|
|
360
379
|
return this.sessions.close(name);
|
|
361
380
|
}
|
|
362
|
-
reapExpiredSessions() {
|
|
363
|
-
this.sessions.reapExpired();
|
|
381
|
+
async reapExpiredSessions() {
|
|
382
|
+
return this.sessions.reapExpired();
|
|
364
383
|
}
|
|
365
384
|
async close() {
|
|
366
385
|
await this.sessions.closeAll();
|