@hheei/ssh-exec-mcp 0.1.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.
@@ -0,0 +1,525 @@
1
+ import { chmod, mkdir, readdir, rm, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export type SessionStatus = "connected" | "reconnecting";
6
+
7
+ export interface Session {
8
+ host: string;
9
+ socketPath: string;
10
+ lastUsed: number;
11
+ status: SessionStatus;
12
+ }
13
+
14
+ export interface ProcessResult {
15
+ exitCode: number | null;
16
+ output?: string;
17
+ stdout: string;
18
+ stderr: string;
19
+ stdoutTruncated?: boolean;
20
+ stderrTruncated?: boolean;
21
+ truncated?: boolean;
22
+ totalBytes?: number;
23
+ outputBytes?: number;
24
+ totalLines?: number;
25
+ outputLines?: number;
26
+ notice?: string;
27
+ }
28
+
29
+ export type ProcessRunner = (args: string[], timeoutMs?: number) => Promise<ProcessResult>;
30
+
31
+ export interface MountProbeResult {
32
+ mounted: boolean;
33
+ healthy: boolean;
34
+ }
35
+
36
+ export type MountProbe = (mountPath: string) => Promise<MountProbeResult>;
37
+ export type MountUnmounter = (mountPath: string) => Promise<boolean>;
38
+ export type SshMountStatus = "mounted" | "reused" | "remounted";
39
+
40
+ export interface SshMountResult {
41
+ host: string;
42
+ localPath: string;
43
+ status: SshMountStatus;
44
+ }
45
+
46
+ export interface SessionManagerOptions {
47
+ sshBin?: string;
48
+ sshfsBin?: string;
49
+ controlDir?: string;
50
+ mountDir?: string;
51
+ platform?: NodeJS.Platform;
52
+ controlPersist?: string;
53
+ supportsControlMaster?: boolean;
54
+ connectTimeoutSeconds?: number;
55
+ connectionAttempts?: number;
56
+ serverAliveIntervalSeconds?: number;
57
+ serverAliveCountMax?: number;
58
+ failureBackoffMs?: number;
59
+ mountProbe?: MountProbe;
60
+ unmountMount?: MountUnmounter;
61
+ }
62
+
63
+ interface HostFailureState {
64
+ failures: number;
65
+ blockedUntil: number;
66
+ }
67
+
68
+ interface MountState {
69
+ host: string;
70
+ mountPath: string;
71
+ lastUsed: number;
72
+ }
73
+
74
+ const DEFAULT_PLUGIN_DIR = join(homedir(), ".codex", "ssh-exec");
75
+ const DEFAULT_CONTROL_DIR = DEFAULT_PLUGIN_DIR;
76
+ const DEFAULT_MOUNT_DIR = join(homedir(), ".cache", "ssh-exec");
77
+ const DEFAULT_CONNECT_TIMEOUT_SECONDS = 30;
78
+ const DEFAULT_MASTER_SETUP_TIMEOUT_MS = 30_000;
79
+ const CONTROL_CHECK_TIMEOUT_MS = 10_000;
80
+
81
+ export class SessionManager {
82
+ readonly sshBin: string;
83
+ readonly sshfsBin: string;
84
+ readonly controlDir: string;
85
+ readonly mountDir: string;
86
+ readonly platform: NodeJS.Platform;
87
+ readonly controlPersist: string;
88
+ readonly supportsControlMaster: boolean;
89
+ readonly connectTimeoutSeconds: number;
90
+ readonly connectionAttempts: number;
91
+ readonly serverAliveIntervalSeconds: number;
92
+ readonly serverAliveCountMax: number;
93
+ readonly failureBackoffMs: number;
94
+
95
+ readonly #sessions = new Map<string, Session>();
96
+ readonly #pending = new Map<string, Promise<Session>>();
97
+ readonly #failures = new Map<string, HostFailureState>();
98
+ readonly #mounts = new Map<string, MountState>();
99
+ readonly #mountProbe: MountProbe;
100
+ readonly #unmountMount: MountUnmounter;
101
+
102
+ constructor(options: SessionManagerOptions = {}) {
103
+ this.sshBin = options.sshBin ?? process.env.SSH_EXEC_SSH_BIN ?? "ssh";
104
+ this.sshfsBin = options.sshfsBin ?? process.env.SSH_EXEC_SSHFS_BIN ?? "sshfs";
105
+ this.controlDir = options.controlDir ?? process.env.SSH_EXEC_CONTROL_DIR ?? DEFAULT_CONTROL_DIR;
106
+ this.mountDir = options.mountDir ?? process.env.SSH_EXEC_MOUNT_DIR ?? DEFAULT_MOUNT_DIR;
107
+ this.platform = options.platform ?? process.platform;
108
+ this.controlPersist = options.controlPersist ?? "3600";
109
+ this.supportsControlMaster = options.supportsControlMaster ?? this.platform !== "win32";
110
+ this.connectTimeoutSeconds = clampPositiveInt(
111
+ options.connectTimeoutSeconds,
112
+ DEFAULT_CONNECT_TIMEOUT_SECONDS,
113
+ );
114
+ this.connectionAttempts = clampPositiveInt(options.connectionAttempts, 2);
115
+ this.serverAliveIntervalSeconds = clampPositiveInt(options.serverAliveIntervalSeconds, 5);
116
+ this.serverAliveCountMax = clampPositiveInt(options.serverAliveCountMax, 1);
117
+ this.failureBackoffMs = Math.max(0, options.failureBackoffMs ?? 15_000);
118
+ this.#mountProbe = options.mountProbe ?? (async (mountPath) => await this.probeMount(mountPath));
119
+ this.#unmountMount = options.unmountMount ?? (async (mountPath) => await this.unmountPath(mountPath));
120
+ }
121
+
122
+ get(host: string): Session {
123
+ const existing = this.#sessions.get(host);
124
+ if (existing) return existing;
125
+
126
+ const session: Session = {
127
+ host,
128
+ socketPath: join(this.controlDir, `${sanitizeHostForSocket(host)}.sock`),
129
+ lastUsed: 0,
130
+ status: "reconnecting",
131
+ };
132
+ this.#sessions.set(host, session);
133
+ return session;
134
+ }
135
+
136
+ getMountPath(host: string): string {
137
+ return join(this.mountDir, sanitizeHostForSocket(host));
138
+ }
139
+
140
+ async ensureConnected(host: string, runner: ProcessRunner): Promise<Session> {
141
+ this.assertHostAvailable(host);
142
+ const pending = this.#pending.get(host);
143
+ if (pending) return await pending;
144
+
145
+ const promise = this.connect(host, runner);
146
+ this.#pending.set(host, promise);
147
+ try {
148
+ return await promise;
149
+ } finally {
150
+ this.#pending.delete(host);
151
+ }
152
+ }
153
+
154
+ async ensureMounted(host: string, runner: ProcessRunner): Promise<SshMountResult> {
155
+ this.assertMountPlatformSupported();
156
+ await this.ensureConnected(host, runner);
157
+ await this.ensureMountDir();
158
+
159
+ const mountPath = this.getMountPath(host);
160
+ await mkdir(mountPath, { recursive: true, mode: 0o700 });
161
+ await chmod(mountPath, 0o700).catch(() => {});
162
+
163
+ const current = await this.#mountProbe(mountPath);
164
+ if (current.mounted && current.healthy) {
165
+ this.#mounts.set(host, { host, mountPath, lastUsed: Date.now() });
166
+ return { host, localPath: mountPath, status: "reused" };
167
+ }
168
+
169
+ let status: SshMountStatus = "mounted";
170
+ if (current.mounted || (await this.directoryHasEntries(mountPath))) {
171
+ await this.#unmountMount(mountPath).catch(() => false);
172
+ status = "remounted";
173
+ }
174
+
175
+ const session = this.get(host);
176
+ const result = await this.runBinary(this.sshfsBin, this.buildSshfsArgs(session, mountPath), 20_000);
177
+ if (result.exitCode !== 0) {
178
+ const detail = result.stderr.trim() || result.stdout.trim();
179
+ const suffix = detail ? `: ${this.sanitize(detail)}` : "";
180
+ throw new Error(`Failed to mount ${host} at ${mountPath}${suffix}`);
181
+ }
182
+
183
+ const mounted = await this.#mountProbe(mountPath);
184
+ if (!mounted.mounted || !mounted.healthy) {
185
+ throw new Error(`Mounted ${host} at ${mountPath}, but the mount probe did not report a healthy sshfs mount`);
186
+ }
187
+
188
+ this.#mounts.set(host, { host, mountPath, lastUsed: Date.now() });
189
+ return { host, localPath: mountPath, status };
190
+ }
191
+
192
+ async closeAll(runner: ProcessRunner, timeoutMs = 5_000): Promise<void> {
193
+ const mounts = Array.from(this.#mounts.values());
194
+ await Promise.allSettled(
195
+ mounts.map(async (mount) => {
196
+ try {
197
+ await this.#unmountMount(mount.mountPath);
198
+ } catch {
199
+ // Cleanup is best-effort.
200
+ } finally {
201
+ this.#mounts.delete(mount.host);
202
+ }
203
+ }),
204
+ );
205
+
206
+ const sessions = Array.from(this.#sessions.values());
207
+ await Promise.allSettled(
208
+ sessions.map(async (session) => {
209
+ try {
210
+ await runner(this.buildControlArgs(session, "exit"), timeoutMs);
211
+ } catch {
212
+ // Cleanup is best-effort. Callers should not block on failed exits.
213
+ } finally {
214
+ this.#sessions.delete(session.host);
215
+ await this.removeSocketIfPresent(session.socketPath);
216
+ }
217
+ }),
218
+ );
219
+ }
220
+
221
+ buildRunArgs(session: Session, command: string): string[] {
222
+ return [...this.buildCommonArgs(session), session.host, command];
223
+ }
224
+
225
+ sensitiveValues(host?: string): string[] {
226
+ const values = Array.from(this.#sessions.values(), (session) => session.socketPath);
227
+ if (host) values.push(this.get(host).socketPath);
228
+ values.push(this.controlDir);
229
+ return values;
230
+ }
231
+
232
+ sanitize(value: string): string {
233
+ let sanitized = value;
234
+ for (const session of this.#sessions.values()) {
235
+ sanitized = sanitized.split(session.socketPath).join("<control-socket>");
236
+ }
237
+ sanitized = sanitized.split(this.controlDir).join("<control-socket-dir>");
238
+ return sanitized;
239
+ }
240
+
241
+ buildControlArgs(session: Session, operation: "check" | "exit"): string[] {
242
+ return ["-O", operation, ...this.buildCommonArgs(session), session.host];
243
+ }
244
+
245
+ buildStartArgs(session: Session): string[] {
246
+ return ["-M", "-N", "-f", ...this.buildCommonArgs(session), session.host];
247
+ }
248
+
249
+ buildCommonArgs(session?: Session): string[] {
250
+ const args = [
251
+ "-n",
252
+ "-o",
253
+ `ConnectTimeout=${this.connectTimeoutSeconds}`,
254
+ "-o",
255
+ `ConnectionAttempts=${this.connectionAttempts}`,
256
+ "-o",
257
+ `ServerAliveInterval=${this.serverAliveIntervalSeconds}`,
258
+ "-o",
259
+ `ServerAliveCountMax=${this.serverAliveCountMax}`,
260
+ "-o",
261
+ "BatchMode=yes",
262
+ "-o",
263
+ "StrictHostKeyChecking=accept-new",
264
+ ];
265
+
266
+ if (this.supportsControlMaster && session) {
267
+ args.push(
268
+ "-S",
269
+ session.socketPath,
270
+ "-o",
271
+ "ControlMaster=auto",
272
+ "-o",
273
+ `ControlPersist=${this.controlPersist}`,
274
+ );
275
+ }
276
+
277
+ return args;
278
+ }
279
+
280
+ buildSshfsArgs(session: Session, mountPath: string): string[] {
281
+ const args = [
282
+ "-o",
283
+ "reconnect",
284
+ "-o",
285
+ `ServerAliveInterval=${this.serverAliveIntervalSeconds}`,
286
+ "-o",
287
+ `ServerAliveCountMax=${this.serverAliveCountMax}`,
288
+ "-o",
289
+ "BatchMode=yes",
290
+ "-o",
291
+ "StrictHostKeyChecking=accept-new",
292
+ ];
293
+
294
+ if (this.supportsControlMaster) {
295
+ args.push(
296
+ "-o",
297
+ "ControlMaster=auto",
298
+ "-o",
299
+ `ControlPath=${session.socketPath}`,
300
+ "-o",
301
+ `ControlPersist=${this.controlPersist}`,
302
+ );
303
+ }
304
+
305
+ args.push(`${session.host}:/`, mountPath);
306
+ return args;
307
+ }
308
+
309
+ private async connect(host: string, runner: ProcessRunner): Promise<Session> {
310
+ const session = this.get(host);
311
+ session.status = "reconnecting";
312
+
313
+ if (!this.supportsControlMaster) {
314
+ session.status = "connected";
315
+ session.lastUsed = Date.now();
316
+ this.markHostSuccess(host);
317
+ return session;
318
+ }
319
+
320
+ await this.ensureControlDir();
321
+ await this.removeStaleSocket(session.socketPath);
322
+ const startedAt = Date.now();
323
+
324
+ try {
325
+ const check = await runner(this.buildControlArgs(session, "check"), CONTROL_CHECK_TIMEOUT_MS);
326
+ if (check.exitCode !== 0) {
327
+ await this.removeSocketIfPresent(session.socketPath);
328
+ const remainingSetupMs = Math.max(
329
+ 1,
330
+ DEFAULT_MASTER_SETUP_TIMEOUT_MS - (Date.now() - startedAt),
331
+ );
332
+ const start = await runner(this.buildStartArgs(session), remainingSetupMs);
333
+ if (start.exitCode !== 0) {
334
+ const detail = start.stderr.trim() || start.stdout.trim();
335
+ const suffix = detail ? `: ${this.sanitize(detail)}` : "";
336
+ throw new Error(`Failed to start SSH master for ${host}${suffix}`);
337
+ }
338
+ }
339
+
340
+ session.status = "connected";
341
+ session.lastUsed = Date.now();
342
+ this.markHostSuccess(host);
343
+ return session;
344
+ } catch (error) {
345
+ this.#sessions.delete(host);
346
+ await this.removeSocketIfPresent(session.socketPath);
347
+ this.markHostFailure(host);
348
+ throw error;
349
+ }
350
+ }
351
+
352
+ private assertHostAvailable(host: string): void {
353
+ const failure = this.#failures.get(host);
354
+ if (!failure) return;
355
+ if (failure.blockedUntil === 0) {
356
+ return;
357
+ }
358
+ if (failure.blockedUntil <= Date.now()) {
359
+ this.#failures.delete(host);
360
+ return;
361
+ }
362
+
363
+ const waitMs = failure.blockedUntil - Date.now();
364
+ throw new Error(`SSH host ${host} is temporarily blocked after repeated failures (${Math.ceil(waitMs / 1000)}s remaining)`);
365
+ }
366
+
367
+ private markHostSuccess(host: string): void {
368
+ this.#failures.delete(host);
369
+ }
370
+
371
+ private markHostFailure(host: string): void {
372
+ const current = this.#failures.get(host);
373
+ const failures = (current?.failures ?? 0) + 1;
374
+ this.#failures.set(host, {
375
+ failures,
376
+ blockedUntil: failures > 1 ? Date.now() + this.failureBackoffMs : 0,
377
+ });
378
+ }
379
+
380
+ private assertMountPlatformSupported(): void {
381
+ if (supportsSshfsMountPlatform(this.platform)) return;
382
+ throw new Error(`ssh_mount is currently supported only on Linux and macOS hosts running Codex locally; current platform is ${this.platform}`);
383
+ }
384
+
385
+ private async ensureControlDir(): Promise<void> {
386
+ await mkdir(this.controlDir, { recursive: true, mode: 0o700 });
387
+ await chmod(this.controlDir, 0o700);
388
+ }
389
+
390
+ private async ensureMountDir(): Promise<void> {
391
+ await mkdir(this.mountDir, { recursive: true, mode: 0o700 });
392
+ await chmod(this.mountDir, 0o700).catch(() => {});
393
+ }
394
+
395
+ private async directoryHasEntries(path: string): Promise<boolean> {
396
+ try {
397
+ return (await readdir(path)).length > 0;
398
+ } catch {
399
+ return false;
400
+ }
401
+ }
402
+
403
+ private async probeMount(mountPath: string): Promise<MountProbeResult> {
404
+ try {
405
+ const stats = await stat(mountPath);
406
+ if (!stats.isDirectory()) return { mounted: false, healthy: false };
407
+ } catch {
408
+ return { mounted: false, healthy: false };
409
+ }
410
+
411
+ const result = await this.runBinary("mount", [], 5_000, { tolerateMissingBinary: true });
412
+ if (result.exitCode !== 0) return { mounted: false, healthy: false };
413
+
414
+ const output = result.stdout || result.output || "";
415
+ const mounted = output.split("\n").some((line) => line.includes(` on ${mountPath} `));
416
+ return { mounted, healthy: mounted };
417
+ }
418
+
419
+ private async unmountPath(mountPath: string): Promise<boolean> {
420
+ const strategies = this.platform === "linux"
421
+ ? [
422
+ { bin: "fusermount", args: ["-u", mountPath] },
423
+ { bin: "fusermount3", args: ["-u", mountPath] },
424
+ { bin: "umount", args: [mountPath] },
425
+ ]
426
+ : [{ bin: "umount", args: [mountPath] }];
427
+
428
+ for (const strategy of strategies) {
429
+ const result = await this.runBinary(strategy.bin, strategy.args, 10_000, { tolerateMissingBinary: true });
430
+ if (result.exitCode === 0) return true;
431
+ if (result.notice !== `${strategy.bin} not found`) return false;
432
+ }
433
+
434
+ return false;
435
+ }
436
+
437
+ private async runBinary(
438
+ bin: string,
439
+ args: string[],
440
+ timeoutMs: number,
441
+ options: { tolerateMissingBinary?: boolean } = {},
442
+ ): Promise<ProcessResult> {
443
+ let child: ReturnType<typeof Bun.spawn>;
444
+ try {
445
+ child = Bun.spawn([bin, ...args], {
446
+ stdin: "ignore",
447
+ stdout: "pipe",
448
+ stderr: "pipe",
449
+ });
450
+ } catch (error) {
451
+ if (options.tolerateMissingBinary && isMissingBinary(error)) {
452
+ return {
453
+ exitCode: null,
454
+ stdout: "",
455
+ stderr: "",
456
+ output: "",
457
+ notice: `${bin} not found`,
458
+ };
459
+ }
460
+ if (isMissingBinary(error)) {
461
+ throw new Error(`${bin} binary not found on PATH`);
462
+ }
463
+ throw error;
464
+ }
465
+
466
+ const timeout = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
467
+ try {
468
+ const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
469
+ child.stdout ? new Response(child.stdout).bytes() : new Uint8Array(),
470
+ child.stderr ? new Response(child.stderr).bytes() : new Uint8Array(),
471
+ child.exited,
472
+ ]);
473
+ const stdout = new TextDecoder().decode(stdoutBytes);
474
+ const stderr = new TextDecoder().decode(stderrBytes);
475
+ return {
476
+ exitCode,
477
+ stdout,
478
+ stderr,
479
+ output: `${stdout}${stderr}`,
480
+ truncated: false,
481
+ };
482
+ } finally {
483
+ clearTimeout(timeout);
484
+ }
485
+ }
486
+
487
+ private async removeStaleSocket(socketPath: string): Promise<void> {
488
+ try {
489
+ const stats = await stat(socketPath);
490
+ if (stats.isFile()) {
491
+ await rm(socketPath, { force: true });
492
+ }
493
+ } catch {
494
+ // Missing path is expected.
495
+ }
496
+ }
497
+
498
+ private async removeSocketIfPresent(socketPath: string): Promise<void> {
499
+ try {
500
+ await rm(socketPath, { force: true });
501
+ } catch {
502
+ // Cleanup is best-effort.
503
+ }
504
+ }
505
+ }
506
+
507
+ function clampPositiveInt(value: number | undefined, fallback: number): number {
508
+ const raw = value ?? fallback;
509
+ if (!Number.isFinite(raw) || raw <= 0) return fallback;
510
+ return Math.max(1, Math.floor(raw));
511
+ }
512
+
513
+ function isMissingBinary(error: unknown): boolean {
514
+ if (!(error instanceof Error)) return false;
515
+ return "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
516
+ }
517
+
518
+ export function sanitizeHostForSocket(host: string): string {
519
+ const readable = host.replace(/[^A-Za-z0-9_.@:-]+/g, "_").replace(/^_+|_+$/g, "");
520
+ return (readable || "host").slice(0, 80);
521
+ }
522
+
523
+ export function supportsSshfsMountPlatform(platform: NodeJS.Platform = process.platform): boolean {
524
+ return platform === "linux" || platform === "darwin";
525
+ }