@aaarc/handfree-ssh-mcp 1.0.9 → 1.0.10

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.
@@ -1,6 +1,6 @@
1
1
  export const SERVER_CONFIG = {
2
2
  name: "ssh-mcp-server",
3
- version: "1.0.9",
3
+ version: "1.0.10",
4
4
  };
5
5
  export const SERVER_INSTRUCTIONS = `This server provides SSH access to servers loaded from OpenSSH config (~/.ssh/config by default) and optional YAML config/policy overlays.
6
6
 
@@ -777,6 +777,60 @@ export class SSHConnectionManager {
777
777
  });
778
778
  });
779
779
  }
780
+ /**
781
+ * Default inactivity window (ms) for SFTP data transfers when the caller does
782
+ * not pass an explicit timeout. Deliberately generous: a healthy transfer
783
+ * moves bytes far more often than this, so tripping it means the connection
784
+ * is effectively dead, not merely slow.
785
+ */
786
+ static DEFAULT_TRANSFER_STALL_TIMEOUT_MS = 60_000;
787
+ /** Chunk size for buffered SFTP writes, sized to yield per-ack progress. */
788
+ static SFTP_WRITE_CHUNK_BYTES = 256 * 1024;
789
+ /**
790
+ * Resolve the inactivity window for a data transfer: the caller's timeout if
791
+ * valid, otherwise the generous default so a dead connection never hangs.
792
+ */
793
+ transferStallTimeout(timeout) {
794
+ return (this.normalizeConnectTimeout(timeout) ??
795
+ SSHConnectionManager.DEFAULT_TRANSFER_STALL_TIMEOUT_MS);
796
+ }
797
+ /**
798
+ * Pipe a readable into a writable under the inactivity watchdog, aborting if
799
+ * no bytes flow for the stall window. Used by the non-fast download and relay
800
+ * paths, which otherwise settle only on close/finish/error and so hang on a
801
+ * dead reused connection. Read-side `data` events drive progress; the caller
802
+ * supplies error mappers so each path keeps its own error code/message.
803
+ */
804
+ pipeWithInactivityTimeout(readStream, writeStream, timeoutMs, description, debug, mapReadError, mapWriteError) {
805
+ const teardown = () => {
806
+ this.unpipeStream(readStream, writeStream);
807
+ this.destroyStream(readStream);
808
+ this.destroyStream(writeStream);
809
+ };
810
+ return this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
811
+ let settled = false;
812
+ const settle = (err) => {
813
+ if (settled)
814
+ return;
815
+ settled = true;
816
+ if (err) {
817
+ teardown();
818
+ reject(err);
819
+ }
820
+ else {
821
+ resolve();
822
+ }
823
+ };
824
+ readStream.on("data", () => onProgress());
825
+ // Resolve on whichever completion event the write side emits: local fs
826
+ // writables emit "finish", SFTP writables emit "close".
827
+ writeStream.on("finish", () => settle());
828
+ writeStream.on("close", () => settle());
829
+ writeStream.on("error", (err) => settle(mapWriteError(err)));
830
+ readStream.on("error", (err) => settle(mapReadError(err)));
831
+ readStream.pipe(writeStream);
832
+ }), timeoutMs, description, debug, teardown);
833
+ }
780
834
  /**
781
835
  * Open a TCP tunnel from the target's jump chain to `config.host:config.port`
782
836
  * and return the duplex stream to be used as `sock` for the target SSH client.
@@ -2111,47 +2165,100 @@ export class SSHConnectionManager {
2111
2165
  }
2112
2166
  /**
2113
2167
  * Read an SFTP file fully into a Buffer.
2168
+ *
2169
+ * Guarded by an inactivity watchdog: if no bytes arrive for the stall window
2170
+ * (a dead reused connection can open the channel but never stream) the read
2171
+ * aborts with a retriable error instead of hanging. An actively-streaming
2172
+ * read is never killed because each chunk resets the watchdog.
2114
2173
  */
2115
2174
  async sftpReadBuffer(client, remotePath, expectedSize, timeout, debug) {
2116
2175
  const sftp = await this.openSftp(client, "read", timeout, debug);
2117
- return new Promise((resolve, reject) => {
2118
- const chunks = [];
2119
- let received = 0;
2120
- const stream = sftp.createReadStream(remotePath);
2121
- stream.on("data", (chunk) => {
2122
- chunks.push(chunk);
2123
- received += chunk.length;
2124
- });
2125
- stream.on("error", (e) => {
2126
- sftp.end();
2127
- reject(this.makeSftpError("Remote read failed", e));
2128
- });
2129
- stream.on("end", () => {
2176
+ const endSftp = () => {
2177
+ try {
2130
2178
  sftp.end();
2131
- if (received !== expectedSize) {
2132
- return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
2133
- }
2134
- resolve(Buffer.concat(chunks, received));
2135
- });
2136
- });
2179
+ }
2180
+ catch {
2181
+ // Ignore late SFTP cleanup errors.
2182
+ }
2183
+ };
2184
+ try {
2185
+ return await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
2186
+ const chunks = [];
2187
+ let received = 0;
2188
+ const stream = sftp.createReadStream(remotePath);
2189
+ stream.on("data", (chunk) => {
2190
+ chunks.push(chunk);
2191
+ received += chunk.length;
2192
+ onProgress();
2193
+ });
2194
+ stream.on("error", (e) => {
2195
+ reject(this.makeSftpError("Remote read failed", e));
2196
+ });
2197
+ stream.on("end", () => {
2198
+ if (received !== expectedSize) {
2199
+ return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
2200
+ }
2201
+ resolve(Buffer.concat(chunks, received));
2202
+ });
2203
+ }), this.transferStallTimeout(timeout), `remote read ${remotePath}`, debug, endSftp);
2204
+ }
2205
+ finally {
2206
+ endSftp();
2207
+ }
2137
2208
  }
2138
2209
  /**
2139
2210
  * Write a Buffer to an SFTP path (overwrites if exists).
2211
+ *
2212
+ * The payload is written in chunks so the inactivity watchdog gets a real
2213
+ * progress signal per acknowledged chunk: a dead reused connection that opens
2214
+ * the channel but never acks a write aborts with a retriable error instead of
2215
+ * hanging, while an actively-flushing write is never killed.
2140
2216
  */
2141
2217
  async sftpWriteBuffer(client, remotePath, payload, timeout, debug) {
2142
2218
  const sftp = await this.openSftp(client, "write", timeout, debug);
2143
- return new Promise((resolve, reject) => {
2144
- const writeStream = sftp.createWriteStream(remotePath);
2145
- writeStream.on("close", () => {
2146
- sftp.end();
2147
- resolve();
2148
- });
2149
- writeStream.on("error", (e) => {
2219
+ const endSftp = () => {
2220
+ try {
2150
2221
  sftp.end();
2151
- reject(this.makeSftpError("File upload failed", e));
2152
- });
2153
- writeStream.end(payload);
2154
- });
2222
+ }
2223
+ catch {
2224
+ // Ignore late SFTP cleanup errors.
2225
+ }
2226
+ };
2227
+ try {
2228
+ await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
2229
+ const writeStream = sftp.createWriteStream(remotePath);
2230
+ let offset = 0;
2231
+ let ended = false;
2232
+ writeStream.on("close", () => resolve());
2233
+ writeStream.on("error", (e) => {
2234
+ reject(this.makeSftpError("File upload failed", e));
2235
+ });
2236
+ const writeNext = () => {
2237
+ if (offset >= payload.length) {
2238
+ if (!ended) {
2239
+ ended = true;
2240
+ writeStream.end();
2241
+ }
2242
+ return;
2243
+ }
2244
+ const end = Math.min(offset + SSHConnectionManager.SFTP_WRITE_CHUNK_BYTES, payload.length);
2245
+ const chunk = payload.subarray(offset, end);
2246
+ offset = end;
2247
+ writeStream.write(chunk, (err) => {
2248
+ if (err) {
2249
+ // The "error" event will reject; nothing else to do here.
2250
+ return;
2251
+ }
2252
+ onProgress();
2253
+ writeNext();
2254
+ });
2255
+ };
2256
+ writeNext();
2257
+ }), this.transferStallTimeout(timeout), `upload ${remotePath}`, debug, endSftp);
2258
+ }
2259
+ finally {
2260
+ endSftp();
2261
+ }
2155
2262
  }
2156
2263
  /**
2157
2264
  * Upload a local file using ssh2's parallel SFTP fastPut implementation.
@@ -2179,7 +2286,7 @@ export class SSHConnectionManager {
2179
2286
  }
2180
2287
  resolve();
2181
2288
  });
2182
- }), this.normalizeConnectTimeout(timeout), `fast upload ${localPath} -> ${remotePath}`, debug, endSftp);
2289
+ }), this.transferStallTimeout(timeout), `fast upload ${localPath} -> ${remotePath}`, debug, endSftp);
2183
2290
  }
2184
2291
  finally {
2185
2292
  endSftp();
@@ -2211,7 +2318,7 @@ export class SSHConnectionManager {
2211
2318
  }
2212
2319
  resolve();
2213
2320
  });
2214
- }), this.normalizeConnectTimeout(timeout), `fast download ${remotePath} -> ${localPath}`, debug, endSftp);
2321
+ }), this.transferStallTimeout(timeout), `fast download ${remotePath} -> ${localPath}`, debug, endSftp);
2215
2322
  }
2216
2323
  finally {
2217
2324
  endSftp();
@@ -2240,27 +2347,17 @@ export class SSHConnectionManager {
2240
2347
  return this.appendDebugOutput("File downloaded successfully via fast SFTP", debugCollector);
2241
2348
  }
2242
2349
  const sftp = await this.openSftp(connection.client, "download", options?.timeout, debug);
2243
- await new Promise((resolve, reject) => {
2244
- let settled = false;
2245
- const readStream = sftp.createReadStream(validatedRemotePath);
2246
- const writeStream = fs.createWriteStream(validatedLocalPath);
2247
- const settle = (err) => {
2248
- if (settled)
2249
- return;
2250
- settled = true;
2251
- if (err) {
2252
- this.unpipeStream(readStream, writeStream);
2253
- this.destroyStream(readStream);
2254
- this.destroyStream(writeStream);
2255
- }
2350
+ try {
2351
+ await this.pipeWithInactivityTimeout(sftp.createReadStream(validatedRemotePath), fs.createWriteStream(validatedLocalPath), this.transferStallTimeout(options?.timeout), `download ${validatedRemotePath} -> ${validatedLocalPath}`, debug, (err) => this.makeSftpError("File download failed", err), (err) => new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false));
2352
+ }
2353
+ finally {
2354
+ try {
2256
2355
  sftp.end();
2257
- err ? reject(err) : resolve();
2258
- };
2259
- writeStream.on("finish", () => settle());
2260
- writeStream.on("error", (err) => settle(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false)));
2261
- readStream.on("error", (err) => settle(this.makeSftpError("File download failed", err)));
2262
- readStream.pipe(writeStream);
2263
- });
2356
+ }
2357
+ catch {
2358
+ // Ignore late SFTP cleanup errors.
2359
+ }
2360
+ }
2264
2361
  return this.appendDebugOutput("File downloaded successfully", debugCollector);
2265
2362
  }
2266
2363
  catch (error) {
@@ -2417,30 +2514,11 @@ export class SSHConnectionManager {
2417
2514
  }
2418
2515
  }
2419
2516
  }
2420
- const readStream = srcSftp.createReadStream(validatedSourcePath);
2421
- const writeStream = dstSftp.createWriteStream(validatedDestPath);
2422
2517
  // Bind the validated paths into the rest of the verification flow so
2423
2518
  // we never accidentally fall back to the un-validated originals.
2424
2519
  sourceRemotePath = validatedSourcePath;
2425
2520
  destRemotePath = validatedDestPath;
2426
- await new Promise((resolve, reject) => {
2427
- let settled = false;
2428
- const settle = (err) => {
2429
- if (settled)
2430
- return;
2431
- settled = true;
2432
- if (err) {
2433
- this.unpipeStream(readStream, writeStream);
2434
- this.destroyStream(readStream);
2435
- this.destroyStream(writeStream);
2436
- }
2437
- err ? reject(err) : resolve();
2438
- };
2439
- writeStream.on("close", () => settle());
2440
- writeStream.on("error", (err) => settle(this.makeSftpError("Dest write error", err)));
2441
- readStream.on("error", (err) => settle(this.makeSftpError("Source read error", err)));
2442
- readStream.pipe(writeStream);
2443
- });
2521
+ await this.pipeWithInactivityTimeout(srcSftp.createReadStream(validatedSourcePath), dstSftp.createWriteStream(validatedDestPath), this.transferStallTimeout(options?.timeout), `relay ${sourceName}:${validatedSourcePath} -> ${destName}:${validatedDestPath}`, debug, (err) => this.makeSftpError("Source read error", err), (err) => this.makeSftpError("Dest write error", err));
2444
2522
  // --- Verification ---
2445
2523
  const dstStat = await this.sftpStat(dstSftp, destRemotePath, "dest");
2446
2524
  const verification = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaarc/handfree-ssh-mcp",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "access": "public"
20
20
  },
21
21
  "scripts": {
22
- "test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js",
22
+ "test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js build/tests/sftp-stall-timeout.test.js",
23
23
  "test:config": "npm run build && node --test build/tests/config.test.js",
24
24
  "test:cmd": "npm run build && node --test build/tests/command-validation.test.js",
25
25
  "test:tools": "npm run build && node --test build/tests/tools.test.js",