ssh-mcp 1.5.0 → 2.0.0
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 +488 -143
- package/build/audit/store.js +178 -0
- package/build/config/credential-resolver.js +107 -0
- package/build/config/loader.js +100 -0
- package/build/config/schema.js +53 -0
- package/build/guard/approval-grants.js +0 -0
- package/build/guard/elicitation.js +38 -0
- package/build/guard/redactor.js +67 -0
- package/build/guard/sanitizer.js +24 -0
- package/build/index.js +183 -628
- package/build/observability/tracer.js +27 -0
- package/build/policy/classifier.js +99 -0
- package/build/policy/engine.js +205 -0
- package/build/policy/quota.js +59 -0
- package/build/ssh/algorithms.js +33 -0
- package/build/ssh/channel-retry.js +15 -0
- package/build/ssh/connection-registry.js +102 -0
- package/build/ssh/connection.js +375 -0
- package/build/ssh/host-key.js +23 -0
- package/build/ssh/session-manager.js +164 -0
- package/build/ssh/session.js +313 -0
- package/build/ssh/sftp.js +130 -0
- package/build/tools/command-tools.js +82 -0
- package/build/tools/descriptions.js +21 -0
- package/build/tools/file-tools.js +36 -0
- package/build/tools/pipeline.js +185 -0
- package/build/tools/registry.js +23 -0
- package/build/tools/resources.js +73 -0
- package/build/tools/results.js +31 -0
- package/build/tools/session-tools.js +97 -0
- package/build/transport/http.js +237 -0
- package/build/types.js +2 -0
- package/build/version.js +9 -0
- package/package.json +28 -7
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { Client } from 'ssh2';
|
|
2
|
+
import { FROZEN_ALGORITHMS } from './algorithms.js';
|
|
3
|
+
import { verifyHostKey, fingerprintPublicKey } from './host-key.js';
|
|
4
|
+
import { SessionManager } from './session-manager.js';
|
|
5
|
+
import { tracer } from '../observability/tracer.js';
|
|
6
|
+
import { redactText } from '../guard/redactor.js';
|
|
7
|
+
import { shellSingleQuote } from '../guard/sanitizer.js';
|
|
8
|
+
import { openWithRetry } from './channel-retry.js';
|
|
9
|
+
export class SSHConnection {
|
|
10
|
+
profile;
|
|
11
|
+
client = null;
|
|
12
|
+
credentials;
|
|
13
|
+
sessions;
|
|
14
|
+
activeChannels = 0;
|
|
15
|
+
connecting = null;
|
|
16
|
+
connected = false;
|
|
17
|
+
connectedAt = null;
|
|
18
|
+
lastActivity = new Date();
|
|
19
|
+
knownHostsStore;
|
|
20
|
+
hostKeyMode;
|
|
21
|
+
bastionSock;
|
|
22
|
+
constructor(profile, credentials, knownHostsStore, hostKeyMode = 'tofu', bastionSock) {
|
|
23
|
+
this.profile = profile;
|
|
24
|
+
this.credentials = credentials;
|
|
25
|
+
this.knownHostsStore = knownHostsStore;
|
|
26
|
+
this.hostKeyMode = hostKeyMode;
|
|
27
|
+
this.bastionSock = bastionSock ?? null;
|
|
28
|
+
this.sessions = new SessionManager({
|
|
29
|
+
// Live read: the profile object can be replaced after construction.
|
|
30
|
+
profile: () => this.profile,
|
|
31
|
+
openShell: () => new Promise((resolve, reject) => {
|
|
32
|
+
this.getClient().shell({ term: 'xterm-256color', cols: 200, rows: 50 }, (err, stream) => (err ? reject(err) : resolve(stream)));
|
|
33
|
+
}),
|
|
34
|
+
openExec: (command) => new Promise((resolve, reject) => {
|
|
35
|
+
this.getClient().exec(this.applyWorkdir(command), (err, stream) => (err ? reject(err) : resolve(stream)));
|
|
36
|
+
}),
|
|
37
|
+
onChannelOpened: () => { this.activeChannels++; },
|
|
38
|
+
onChannelClosed: () => { this.activeChannels--; },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async ensureConnected() {
|
|
42
|
+
if (this.isConnected())
|
|
43
|
+
return;
|
|
44
|
+
if (this.connecting)
|
|
45
|
+
return this.connecting;
|
|
46
|
+
this.connecting = this.connect();
|
|
47
|
+
return this.connecting;
|
|
48
|
+
}
|
|
49
|
+
async connect() {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
// Every handler below acts on `client`, not `this.client`, and only
|
|
52
|
+
// mutates shared state while it is still the current client. A late event
|
|
53
|
+
// from a superseded attempt must not tear down the connection that
|
|
54
|
+
// replaced it.
|
|
55
|
+
const client = new Client();
|
|
56
|
+
this.client = client;
|
|
57
|
+
const isCurrent = () => this.client === client;
|
|
58
|
+
let settled = false;
|
|
59
|
+
const settle = (fn) => {
|
|
60
|
+
if (settled)
|
|
61
|
+
return;
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timeoutId);
|
|
64
|
+
if (isCurrent())
|
|
65
|
+
this.connecting = null;
|
|
66
|
+
fn();
|
|
67
|
+
};
|
|
68
|
+
const timeoutId = setTimeout(() => {
|
|
69
|
+
settle(() => {
|
|
70
|
+
try {
|
|
71
|
+
client.end();
|
|
72
|
+
}
|
|
73
|
+
catch { /* already gone */ }
|
|
74
|
+
if (isCurrent())
|
|
75
|
+
this.client = null;
|
|
76
|
+
reject(new Error('SSH connection timeout'));
|
|
77
|
+
});
|
|
78
|
+
}, 20000);
|
|
79
|
+
client.on('ready', () => {
|
|
80
|
+
settle(() => {
|
|
81
|
+
this.connected = true;
|
|
82
|
+
this.connectedAt = new Date();
|
|
83
|
+
this.lastActivity = new Date();
|
|
84
|
+
resolve();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
client.on('error', (err) => {
|
|
88
|
+
settle(() => {
|
|
89
|
+
if (isCurrent())
|
|
90
|
+
this.client = null;
|
|
91
|
+
reject(new Error(`SSH connection error: ${err.message}`));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
// 'end'/'close' also fire long after a successful handshake, when the
|
|
95
|
+
// connection drops — that path must clear connection state but leave the
|
|
96
|
+
// already-resolved promise alone.
|
|
97
|
+
const onDisconnect = (reason) => () => {
|
|
98
|
+
if (!isCurrent())
|
|
99
|
+
return;
|
|
100
|
+
this.connected = false;
|
|
101
|
+
this.sessions.markAllDisconnected();
|
|
102
|
+
this.client = null;
|
|
103
|
+
this.connectedAt = null;
|
|
104
|
+
// Without clearing the timer here, a handshake that fails via a clean
|
|
105
|
+
// TCP close left a 20s bomb armed: it would later end a *newer* client
|
|
106
|
+
// mid-handshake and null its `connecting`, leaving that attempt hung.
|
|
107
|
+
settle(() => reject(new Error(`SSH connection ${reason} during handshake`)));
|
|
108
|
+
};
|
|
109
|
+
client.on('end', onDisconnect('ended'));
|
|
110
|
+
client.on('close', onDisconnect('closed'));
|
|
111
|
+
const connectConfig = {
|
|
112
|
+
host: this.profile.host,
|
|
113
|
+
port: this.profile.port,
|
|
114
|
+
username: this.profile.user,
|
|
115
|
+
algorithms: FROZEN_ALGORITHMS,
|
|
116
|
+
hostVerifier: (key) => {
|
|
117
|
+
const fp = fingerprintPublicKey(key);
|
|
118
|
+
if (this.profile.trustedHostKey && fp !== this.profile.trustedHostKey) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return verifyHostKey(this.profile.host, this.profile.port, fp, this.knownHostsStore, this.hostKeyMode);
|
|
122
|
+
},
|
|
123
|
+
readyTimeout: 20000,
|
|
124
|
+
keepaliveInterval: 15000,
|
|
125
|
+
keepaliveCountMax: 3,
|
|
126
|
+
};
|
|
127
|
+
if (this.credentials.agentSocket) {
|
|
128
|
+
connectConfig.agent = this.credentials.agentSocket;
|
|
129
|
+
}
|
|
130
|
+
if (this.credentials.privateKey) {
|
|
131
|
+
connectConfig.privateKey = this.credentials.certificate
|
|
132
|
+
? this.credentials.privateKey + '\n' + this.credentials.certificate
|
|
133
|
+
: this.credentials.privateKey;
|
|
134
|
+
if (this.credentials.passphrase) {
|
|
135
|
+
connectConfig.passphrase = this.credentials.passphrase;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (this.credentials.password) {
|
|
139
|
+
connectConfig.password = this.credentials.password;
|
|
140
|
+
}
|
|
141
|
+
if (this.bastionSock) {
|
|
142
|
+
connectConfig.sock = this.bastionSock;
|
|
143
|
+
}
|
|
144
|
+
client.connect(connectConfig);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
isConnected() {
|
|
148
|
+
return this.connected && this.client !== null;
|
|
149
|
+
}
|
|
150
|
+
getClient() {
|
|
151
|
+
if (!this.client)
|
|
152
|
+
throw new Error('SSH connection not established');
|
|
153
|
+
return this.client;
|
|
154
|
+
}
|
|
155
|
+
getSudoPassword() {
|
|
156
|
+
return this.credentials.sudoPassword;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Prefix a command with the profile's working directory, if configured.
|
|
160
|
+
* Applied here rather than at the tool layer so the string the policy engine
|
|
161
|
+
* classified is never the string that carries the `&&`.
|
|
162
|
+
*/
|
|
163
|
+
applyWorkdir(command) {
|
|
164
|
+
if (!this.profile.workdir)
|
|
165
|
+
return command;
|
|
166
|
+
return `cd ${shellSingleQuote(this.profile.workdir)} && ${command}`;
|
|
167
|
+
}
|
|
168
|
+
async exec(rawCommand, opts = {}) {
|
|
169
|
+
await this.ensureConnected();
|
|
170
|
+
const command = this.applyWorkdir(rawCommand);
|
|
171
|
+
const timeoutMs = opts.timeoutMs ?? this.profile.timeout;
|
|
172
|
+
const startTime = Date.now();
|
|
173
|
+
const span = tracer.startSpan('ssh.exec');
|
|
174
|
+
span.setAttribute('ssh.host', this.profile.host);
|
|
175
|
+
span.setAttribute('ssh.port', this.profile.port);
|
|
176
|
+
span.setAttribute('ssh.command', redactText(command));
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
let activeStream = null;
|
|
179
|
+
let resolved = false;
|
|
180
|
+
const timeoutId = setTimeout(() => {
|
|
181
|
+
if (!resolved) {
|
|
182
|
+
resolved = true;
|
|
183
|
+
if (activeStream) {
|
|
184
|
+
try {
|
|
185
|
+
activeStream.signal('INT');
|
|
186
|
+
}
|
|
187
|
+
catch { /* */ }
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
try {
|
|
190
|
+
activeStream?.signal('TERM');
|
|
191
|
+
}
|
|
192
|
+
catch { /* */ }
|
|
193
|
+
setTimeout(() => { try {
|
|
194
|
+
activeStream?.close();
|
|
195
|
+
}
|
|
196
|
+
catch { /* */ } }, 1000);
|
|
197
|
+
}, 1000);
|
|
198
|
+
}
|
|
199
|
+
span.setAttribute('ssh.timedOut', true);
|
|
200
|
+
span.end();
|
|
201
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
202
|
+
}
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
const execOpts = {};
|
|
205
|
+
if (opts.tty || this.profile.tty) {
|
|
206
|
+
execOpts.pty = { term: 'xterm-256color', cols: 200, rows: 50 };
|
|
207
|
+
}
|
|
208
|
+
// Retried: some servers (Dropbear especially) intermittently refuse a
|
|
209
|
+
// channel right after a previous one was released. Only the open is
|
|
210
|
+
// retried — once a stream exists, nothing here re-runs the command.
|
|
211
|
+
const openExecChannel = async () => {
|
|
212
|
+
// Re-established per attempt: the refusal can be a dropped connection
|
|
213
|
+
// rather than a refused channel.
|
|
214
|
+
await this.ensureConnected();
|
|
215
|
+
const activeClient = this.getClient();
|
|
216
|
+
return new Promise((res, rej) => {
|
|
217
|
+
activeClient.exec(command, execOpts, (err, stream) => (err ? rej(err) : res(stream)));
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
const onExecChannel = (err, stream) => {
|
|
221
|
+
if (err) {
|
|
222
|
+
if (!resolved) {
|
|
223
|
+
resolved = true;
|
|
224
|
+
clearTimeout(timeoutId);
|
|
225
|
+
span.end();
|
|
226
|
+
reject(new Error(`SSH exec error: ${err.message}`));
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
activeStream = stream;
|
|
231
|
+
this.activeChannels++;
|
|
232
|
+
let stdout = '';
|
|
233
|
+
let stderr = '';
|
|
234
|
+
const maxOutput = this.profile.maxOutputBytes;
|
|
235
|
+
let lastProgressSent = 0;
|
|
236
|
+
const PROGRESS_INTERVAL = 500;
|
|
237
|
+
if (opts.stdin) {
|
|
238
|
+
try {
|
|
239
|
+
stream.write(opts.stdin);
|
|
240
|
+
}
|
|
241
|
+
catch { /* */ }
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
stream.end();
|
|
245
|
+
}
|
|
246
|
+
catch { /* */ }
|
|
247
|
+
if (opts.abortSignal) {
|
|
248
|
+
if (opts.abortSignal.aborted) {
|
|
249
|
+
resolved = true;
|
|
250
|
+
clearTimeout(timeoutId);
|
|
251
|
+
this.activeChannels--;
|
|
252
|
+
// client.exec() has already dispatched the command, so rejecting
|
|
253
|
+
// without signalling left it running to completion on the host and
|
|
254
|
+
// held a channel until it finished.
|
|
255
|
+
try {
|
|
256
|
+
stream.signal('INT');
|
|
257
|
+
}
|
|
258
|
+
catch { /* */ }
|
|
259
|
+
try {
|
|
260
|
+
stream.close();
|
|
261
|
+
}
|
|
262
|
+
catch { /* */ }
|
|
263
|
+
span.end();
|
|
264
|
+
reject(new Error('Command aborted before execution'));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const onAbort = () => {
|
|
268
|
+
if (!resolved) {
|
|
269
|
+
resolved = true;
|
|
270
|
+
clearTimeout(timeoutId);
|
|
271
|
+
try {
|
|
272
|
+
stream.signal('INT');
|
|
273
|
+
}
|
|
274
|
+
catch { /* */ }
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
try {
|
|
277
|
+
stream.signal('TERM');
|
|
278
|
+
}
|
|
279
|
+
catch { /* */ }
|
|
280
|
+
setTimeout(() => { try {
|
|
281
|
+
stream.close();
|
|
282
|
+
}
|
|
283
|
+
catch { /* */ } }, 1000);
|
|
284
|
+
}, 1000);
|
|
285
|
+
span.end();
|
|
286
|
+
reject(new Error('Command aborted'));
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
opts.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
290
|
+
stream.on('close', () => { opts.abortSignal.removeEventListener('abort', onAbort); });
|
|
291
|
+
}
|
|
292
|
+
stream.on('data', (data) => {
|
|
293
|
+
if (stdout.length < maxOutput)
|
|
294
|
+
stdout += data.toString();
|
|
295
|
+
if (opts.onProgress && Date.now() - lastProgressSent >= PROGRESS_INTERVAL) {
|
|
296
|
+
lastProgressSent = Date.now();
|
|
297
|
+
let tail = stdout;
|
|
298
|
+
for (let i = 0; i < 3; i++) {
|
|
299
|
+
const idx = tail.lastIndexOf('\n', tail.length - 2);
|
|
300
|
+
if (idx < 0)
|
|
301
|
+
break;
|
|
302
|
+
tail = tail.substring(idx + 1);
|
|
303
|
+
}
|
|
304
|
+
opts.onProgress(stdout.length, redactText(tail.trim(), { entropyScan: true }));
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
stream.stderr.on('data', (data) => {
|
|
308
|
+
if (stderr.length < maxOutput)
|
|
309
|
+
stderr += data.toString();
|
|
310
|
+
});
|
|
311
|
+
stream.on('close', (code, signal) => {
|
|
312
|
+
this.activeChannels--;
|
|
313
|
+
if (!resolved) {
|
|
314
|
+
resolved = true;
|
|
315
|
+
clearTimeout(timeoutId);
|
|
316
|
+
this.lastActivity = new Date();
|
|
317
|
+
resolve({
|
|
318
|
+
stdout,
|
|
319
|
+
stderr,
|
|
320
|
+
exitCode: code,
|
|
321
|
+
durationMs: Date.now() - startTime,
|
|
322
|
+
profile: this.profile.name,
|
|
323
|
+
signal: signal || undefined,
|
|
324
|
+
});
|
|
325
|
+
span.setAttribute('ssh.exitCode', code);
|
|
326
|
+
if (signal)
|
|
327
|
+
span.setAttribute('ssh.signal', signal);
|
|
328
|
+
span.end();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
openWithRetry(openExecChannel).then((stream) => onExecChannel(undefined, stream), (err) => onExecChannel(err, undefined));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
// ─── Sessions (delegated to SessionManager) ──────────────────────────
|
|
336
|
+
async openSession(opts) {
|
|
337
|
+
await this.ensureConnected();
|
|
338
|
+
return this.sessions.open(opts);
|
|
339
|
+
}
|
|
340
|
+
getSession(name) {
|
|
341
|
+
return this.sessions.get(name);
|
|
342
|
+
}
|
|
343
|
+
listSessions() {
|
|
344
|
+
return this.sessions.list();
|
|
345
|
+
}
|
|
346
|
+
async closeSession(name) {
|
|
347
|
+
return this.sessions.close(name);
|
|
348
|
+
}
|
|
349
|
+
reapExpiredSessions() {
|
|
350
|
+
this.sessions.reapExpired();
|
|
351
|
+
}
|
|
352
|
+
async close() {
|
|
353
|
+
await this.sessions.closeAll();
|
|
354
|
+
if (this.client) {
|
|
355
|
+
this.client.end();
|
|
356
|
+
this.client = null;
|
|
357
|
+
}
|
|
358
|
+
this.connecting = null;
|
|
359
|
+
this.connected = false;
|
|
360
|
+
this.connectedAt = null;
|
|
361
|
+
}
|
|
362
|
+
toInfo() {
|
|
363
|
+
return {
|
|
364
|
+
profile: this.profile.name,
|
|
365
|
+
host: this.profile.host,
|
|
366
|
+
port: this.profile.port,
|
|
367
|
+
user: this.profile.user,
|
|
368
|
+
status: this.isConnected() ? 'connected' : 'closed',
|
|
369
|
+
sessionCount: this.sessions.size,
|
|
370
|
+
activeChannels: this.activeChannels,
|
|
371
|
+
connectedAt: this.connectedAt || undefined,
|
|
372
|
+
lastActivity: this.lastActivity,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
export function fingerprintPublicKey(key) {
|
|
3
|
+
const hash = createHash('sha256').update(key).digest('base64');
|
|
4
|
+
return `SHA256:${hash.replace(/=+$/, '')}`;
|
|
5
|
+
}
|
|
6
|
+
export function verifyHostKey(host, port, fingerprint, knownHosts, mode) {
|
|
7
|
+
if (mode === 'insecure')
|
|
8
|
+
return true;
|
|
9
|
+
const key = `${host}:${port}`;
|
|
10
|
+
const stored = knownHosts.get(key);
|
|
11
|
+
if (stored) {
|
|
12
|
+
if (stored !== fingerprint) {
|
|
13
|
+
throw new Error(`HOST_KEY_MISMATCH: ${host}:${port} key changed (expected ${stored.slice(0, 20)}..., got ${fingerprint.slice(0, 20)}...)`);
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (mode === 'strict') {
|
|
18
|
+
throw new Error(`HOST_KEY_UNKNOWN: No known key for ${host}:${port} and --hostKeyMode=strict is set. ` +
|
|
19
|
+
'Pin the key with trustedHostKey in the profile, or use --hostKeyMode=tofu to trust on first use.');
|
|
20
|
+
}
|
|
21
|
+
knownHosts.set(key, fingerprint);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { InteractiveSession, BackgroundSession, stripAnsi } from './session.js';
|
|
2
|
+
import { shellSingleQuote } from '../guard/sanitizer.js';
|
|
3
|
+
import { openWithRetry } from './channel-retry.js';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
/** Owns the named sessions of a single SSH connection. */
|
|
6
|
+
export class SessionManager {
|
|
7
|
+
deps;
|
|
8
|
+
sessions = new Map();
|
|
9
|
+
constructor(deps) {
|
|
10
|
+
this.deps = deps;
|
|
11
|
+
}
|
|
12
|
+
get size() {
|
|
13
|
+
return this.sessions.size;
|
|
14
|
+
}
|
|
15
|
+
async open(opts) {
|
|
16
|
+
const profile = this.deps.profile();
|
|
17
|
+
if (this.sessions.size >= profile.sessionMaxPerConnection) {
|
|
18
|
+
throw new Error(`Session limit reached for ${profile.name} (max: ${profile.sessionMaxPerConnection})`);
|
|
19
|
+
}
|
|
20
|
+
if (this.sessions.has(opts.name)) {
|
|
21
|
+
throw new Error(`Session "${opts.name}" already exists on ${profile.name}`);
|
|
22
|
+
}
|
|
23
|
+
const ttlMs = opts.ttlMs ?? profile.sessionIdleTimeoutMs;
|
|
24
|
+
const id = randomUUID();
|
|
25
|
+
if (opts.type === 'interactive') {
|
|
26
|
+
return this.openInteractive(id, opts.name, ttlMs);
|
|
27
|
+
}
|
|
28
|
+
if (!opts.command)
|
|
29
|
+
throw new Error('Background sessions require a command');
|
|
30
|
+
return this.openBackground(id, opts.name, opts.command, ttlMs);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Register a session and tie its lifetime to its channel.
|
|
34
|
+
*
|
|
35
|
+
* The eviction is guarded: close() only ends the stream, so a channel's
|
|
36
|
+
* 'close' can arrive long after the same name was reused, and an unguarded
|
|
37
|
+
* delete would evict the *new* session — leaving its channel open but
|
|
38
|
+
* unreachable through get/list/close/reap.
|
|
39
|
+
*/
|
|
40
|
+
register(name, session, stream) {
|
|
41
|
+
this.sessions.set(name, session);
|
|
42
|
+
this.deps.onChannelOpened();
|
|
43
|
+
stream.on('close', () => {
|
|
44
|
+
this.deps.onChannelClosed();
|
|
45
|
+
if (this.sessions.get(name) === session)
|
|
46
|
+
this.sessions.delete(name);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async openInteractive(id, name, ttlMs) {
|
|
50
|
+
let stream;
|
|
51
|
+
try {
|
|
52
|
+
stream = await openWithRetry(() => this.deps.openShell());
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new Error(`Failed to open interactive session: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
const session = new InteractiveSession(id, name, this.deps.profile().name, stream, ttlMs);
|
|
58
|
+
this.register(name, session, stream);
|
|
59
|
+
try {
|
|
60
|
+
await this.primeShell(stream, session);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
await session.close().catch(() => { });
|
|
64
|
+
this.sessions.delete(name);
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
return session;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Quiet the shell down and confirm it can run the session protocol — in one
|
|
71
|
+
* round trip.
|
|
72
|
+
*
|
|
73
|
+
* Priming suppresses the prompt, history and bracketed paste, all of which
|
|
74
|
+
* would otherwise land in command output. The readiness marker at the end
|
|
75
|
+
* doubles as the handshake: it comes back only from a shell that can run
|
|
76
|
+
* `printf`, which is the same capability every later command depends on.
|
|
77
|
+
*
|
|
78
|
+
* This replaced two weaker mechanisms. Waiting for a prompt with /[#$>]\s*$/
|
|
79
|
+
* was a guess that failed on busybox ash (its prompt is followed by a
|
|
80
|
+
* cursor-position query) and would silently "succeed" on cmd.exe, which
|
|
81
|
+
* presents a `>` prompt and then cannot run a single command — every call
|
|
82
|
+
* afterwards sat until the 60s command timeout with nothing useful to say.
|
|
83
|
+
*/
|
|
84
|
+
primeShell(stream, session) {
|
|
85
|
+
const { workdir, name: profileName } = this.deps.profile();
|
|
86
|
+
// Split literals: the echoed input never contains the assembled marker, so
|
|
87
|
+
// only the shell's own output can satisfy the match.
|
|
88
|
+
const parts = ['SSHMCP_RDY', `_${randomUUID()}`];
|
|
89
|
+
const marker = parts.join('');
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
let buffer = '';
|
|
92
|
+
let settled = false;
|
|
93
|
+
const settle = (err) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
stream.removeListener('data', onData);
|
|
99
|
+
err ? reject(err) : resolve();
|
|
100
|
+
};
|
|
101
|
+
const onData = (data) => {
|
|
102
|
+
buffer += data.toString();
|
|
103
|
+
if (stripAnsi(buffer).includes(marker))
|
|
104
|
+
settle();
|
|
105
|
+
};
|
|
106
|
+
const timer = setTimeout(() => settle(new Error(`Interactive sessions are not supported on ${profileName}: its shell did not complete the ` +
|
|
107
|
+
'session handshake. The protocol requires a POSIX shell (sh/bash/ash/zsh); cmd.exe and ' +
|
|
108
|
+
'PowerShell do not provide it. Use read-command / run-command instead, which work on any shell.')), 5000);
|
|
109
|
+
stream.on('data', onData);
|
|
110
|
+
stream.write('PS1=""\n');
|
|
111
|
+
stream.write('set +o history 2>/dev/null\n');
|
|
112
|
+
stream.write('bind "set enable-bracketed-paste off" 2>/dev/null\n');
|
|
113
|
+
if (workdir) {
|
|
114
|
+
stream.write(`cd ${shellSingleQuote(workdir)}\n`);
|
|
115
|
+
session.setCwd(workdir);
|
|
116
|
+
}
|
|
117
|
+
stream.write(`printf '%s%s\\n' '${parts[0]}' '${parts[1]}'\n`);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async openBackground(id, name, command, ttlMs) {
|
|
121
|
+
let stream;
|
|
122
|
+
try {
|
|
123
|
+
stream = await openWithRetry(() => this.deps.openExec(command));
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
throw new Error(`Failed to open background session: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
}
|
|
128
|
+
const profile = this.deps.profile();
|
|
129
|
+
const session = new BackgroundSession(id, name, profile.name, stream, ttlMs, profile.sessionBackgroundMaxMs);
|
|
130
|
+
this.register(name, session, stream);
|
|
131
|
+
return session;
|
|
132
|
+
}
|
|
133
|
+
get(name) {
|
|
134
|
+
return this.sessions.get(name);
|
|
135
|
+
}
|
|
136
|
+
list() {
|
|
137
|
+
return Array.from(this.sessions.values());
|
|
138
|
+
}
|
|
139
|
+
async close(name) {
|
|
140
|
+
const session = this.sessions.get(name);
|
|
141
|
+
if (!session)
|
|
142
|
+
throw new Error(`Session "${name}" not found on ${this.deps.profile().name}`);
|
|
143
|
+
await session.close();
|
|
144
|
+
this.sessions.delete(name);
|
|
145
|
+
}
|
|
146
|
+
async closeAll() {
|
|
147
|
+
for (const name of [...this.sessions.keys()]) {
|
|
148
|
+
await this.close(name).catch(() => { });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
reapExpired() {
|
|
152
|
+
for (const [name, session] of this.sessions) {
|
|
153
|
+
if (session.isExpired()) {
|
|
154
|
+
session.close().catch(() => { });
|
|
155
|
+
this.sessions.delete(name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
markAllDisconnected() {
|
|
160
|
+
for (const session of this.sessions.values()) {
|
|
161
|
+
session.markDisconnected();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|