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/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 { loadConfig, getConfigPath } from './config/loader.js';
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, HOST_GROUPS, resolvePolicyRules } from './policy/engine.js';
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
- function parseArgv() {
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
- for (const info of registry.listConnections()) {
272
- const conn = registry.get(info.profile);
273
- if (conn) {
274
- conn.reapExpiredSessions();
275
- }
276
- }
277
- registry.reapIdleConnections();
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 registry.closeAll();
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 (const conn of this.connections.values()) {
98
- await conn.close().catch(() => { });
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
  }
@@ -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
- if (activeStream) {
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
- try {
269
- stream.signal('INT');
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('Command aborted before execution'));
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
- try {
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('Command aborted'));
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();