@aaarc/handfree-ssh-mcp 1.0.0 → 1.0.2
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 +166 -133
- package/build/config/config-loader.js +60 -0
- package/build/config/server.js +13 -13
- package/build/config/ssh-config-loader.js +45 -2
- package/build/core/mcp-server.js +0 -5
- package/build/services/ssh-connection-manager.js +274 -164
- package/build/tools/execute-command.js +1 -1
- package/build/tools/help.js +4 -4
- package/build/tools/list-servers.js +6 -4
- package/build/tools/show-whitelist.js +67 -42
- package/package.json +20 -11
- package/build/tests/command-validation.test.js +0 -876
- package/build/tests/config.test.js +0 -617
- package/build/tests/integration.test.js +0 -348
- package/build/tests/output-collector.test.js +0 -70
- package/build/tests/output-log-writer.test.js +0 -205
- package/build/tests/recent-fixes.test.js +0 -229
- package/build/tests/tool-error.test.js +0 -26
- package/build/tests/tools.test.js +0 -486
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* handfree-ssh-mcp Integration Tests
|
|
4
|
-
*
|
|
5
|
-
* Tests with REAL SSH connections using servers.yaml config.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* npm run test:integration
|
|
9
|
-
* npm run test:integration -- --server dev
|
|
10
|
-
* npm run test:integration -- --server prod
|
|
11
|
-
*
|
|
12
|
-
* Or directly:
|
|
13
|
-
* node build/tests/integration.test.js
|
|
14
|
-
* node build/tests/integration.test.js --server dev
|
|
15
|
-
*/
|
|
16
|
-
import path from "node:path";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
-
import { loadConfigFromYaml } from "../config/config-loader.js";
|
|
19
|
-
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
|
20
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
-
const __dirname = path.dirname(__filename);
|
|
22
|
-
// Colors for terminal output
|
|
23
|
-
const colors = {
|
|
24
|
-
reset: "\x1b[0m",
|
|
25
|
-
green: "\x1b[32m",
|
|
26
|
-
red: "\x1b[31m",
|
|
27
|
-
yellow: "\x1b[33m",
|
|
28
|
-
cyan: "\x1b[36m",
|
|
29
|
-
dim: "\x1b[2m",
|
|
30
|
-
};
|
|
31
|
-
function log(msg, color = "reset") {
|
|
32
|
-
console.log(`${colors[color]}${msg}${colors.reset}`);
|
|
33
|
-
}
|
|
34
|
-
function success(msg) {
|
|
35
|
-
log(` ✅ ${msg}`, "green");
|
|
36
|
-
}
|
|
37
|
-
function fail(msg) {
|
|
38
|
-
log(` ❌ ${msg}`, "red");
|
|
39
|
-
}
|
|
40
|
-
function info(msg) {
|
|
41
|
-
log(` ℹ️ ${msg}`, "cyan");
|
|
42
|
-
}
|
|
43
|
-
function section(msg) {
|
|
44
|
-
console.log();
|
|
45
|
-
log(`━━━ ${msg} ━━━`, "yellow");
|
|
46
|
-
}
|
|
47
|
-
const results = [];
|
|
48
|
-
async function runTest(name, fn) {
|
|
49
|
-
const start = Date.now();
|
|
50
|
-
try {
|
|
51
|
-
await fn();
|
|
52
|
-
const duration = Date.now() - start;
|
|
53
|
-
results.push({ name, passed: true, duration });
|
|
54
|
-
success(`${name} (${duration}ms)`);
|
|
55
|
-
}
|
|
56
|
-
catch (error) {
|
|
57
|
-
const duration = Date.now() - start;
|
|
58
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
59
|
-
results.push({ name, passed: false, error: errorMsg, duration });
|
|
60
|
-
fail(`${name}: ${errorMsg}`);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
async function main() {
|
|
64
|
-
// Parse args
|
|
65
|
-
const args = process.argv.slice(2);
|
|
66
|
-
const serverArgIndex = args.indexOf("--server");
|
|
67
|
-
const targetServer = serverArgIndex !== -1 ? args[serverArgIndex + 1] : null;
|
|
68
|
-
// Find config file (use root project servers.yaml)
|
|
69
|
-
const configPath = path.resolve(__dirname, "../../../../servers.yaml");
|
|
70
|
-
log("\n🧪 handfree-ssh-mcp Integration Tests\n", "cyan");
|
|
71
|
-
info(`Config: ${configPath}`);
|
|
72
|
-
if (targetServer) {
|
|
73
|
-
info(`Target server: ${targetServer}`);
|
|
74
|
-
}
|
|
75
|
-
// Load config
|
|
76
|
-
section("Loading Configuration");
|
|
77
|
-
let parsedArgs;
|
|
78
|
-
try {
|
|
79
|
-
parsedArgs = loadConfigFromYaml(configPath);
|
|
80
|
-
success(`Loaded ${Object.keys(parsedArgs.configs).length} server(s)`);
|
|
81
|
-
for (const name of Object.keys(parsedArgs.configs)) {
|
|
82
|
-
const cfg = parsedArgs.configs[name];
|
|
83
|
-
info(` ${name}: ${cfg.username}@${cfg.host}:${cfg.port}`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
catch (error) {
|
|
87
|
-
fail(`Failed to load config: ${error}`);
|
|
88
|
-
process.exit(1);
|
|
89
|
-
}
|
|
90
|
-
// Determine which servers to test
|
|
91
|
-
const serversToTest = targetServer
|
|
92
|
-
? [targetServer]
|
|
93
|
-
: Object.keys(parsedArgs.configs);
|
|
94
|
-
// Validate server selection
|
|
95
|
-
for (const server of serversToTest) {
|
|
96
|
-
if (!parsedArgs.configs[server]) {
|
|
97
|
-
fail(`Server '${server}' not found in config`);
|
|
98
|
-
process.exit(1);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// Initialize SSH manager
|
|
102
|
-
const sshManager = SSHConnectionManager.getInstance();
|
|
103
|
-
sshManager.setConfig(parsedArgs.configs, serversToTest);
|
|
104
|
-
// Run tests for each server
|
|
105
|
-
for (const serverName of serversToTest) {
|
|
106
|
-
section(`Testing: ${serverName}`);
|
|
107
|
-
const config = parsedArgs.configs[serverName];
|
|
108
|
-
info(`Host: ${config.host}:${config.port}`);
|
|
109
|
-
info(`User: ${config.username}`);
|
|
110
|
-
info(`Auth: ${config.password ? "password" : "privateKey"}`);
|
|
111
|
-
info(`Whitelist patterns: ${config.commandWhitelist?.length || 0}`);
|
|
112
|
-
// Test: Basic connection
|
|
113
|
-
await runTest(`[${serverName}] SSH Connection`, async () => {
|
|
114
|
-
const result = await sshManager.executeCommand("echo 'connection test'", serverName, { timeout: 10000 });
|
|
115
|
-
if (!result.includes("connection test")) {
|
|
116
|
-
throw new Error(`Unexpected output: ${result}`);
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
// Test: pwd command
|
|
120
|
-
await runTest(`[${serverName}] pwd command`, async () => {
|
|
121
|
-
const result = await sshManager.executeCommand("pwd", serverName, { timeout: 5000 });
|
|
122
|
-
if (!result.startsWith("/")) {
|
|
123
|
-
throw new Error(`Expected path starting with /, got: ${result}`);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
// Test: ls command
|
|
127
|
-
await runTest(`[${serverName}] ls command`, async () => {
|
|
128
|
-
const result = await sshManager.executeCommand("ls -la", serverName, { timeout: 5000 });
|
|
129
|
-
if (!result.includes("total")) {
|
|
130
|
-
throw new Error(`Expected 'total' in ls output, got: ${result.substring(0, 100)}`);
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
// Test: hostname
|
|
134
|
-
await runTest(`[${serverName}] hostname command`, async () => {
|
|
135
|
-
const result = await sshManager.executeCommand("hostname", serverName, { timeout: 5000 });
|
|
136
|
-
if (result.trim().length === 0) {
|
|
137
|
-
throw new Error("Empty hostname");
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
// Test: whoami
|
|
141
|
-
await runTest(`[${serverName}] whoami command`, async () => {
|
|
142
|
-
const result = await sshManager.executeCommand("whoami", serverName, { timeout: 5000 });
|
|
143
|
-
if (result.trim() !== config.username) {
|
|
144
|
-
throw new Error(`Expected ${config.username}, got: ${result.trim()}`);
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
// Test: date
|
|
148
|
-
await runTest(`[${serverName}] date command`, async () => {
|
|
149
|
-
const result = await sshManager.executeCommand("date", serverName, { timeout: 5000 });
|
|
150
|
-
// Should contain year
|
|
151
|
-
if (!result.includes("202")) {
|
|
152
|
-
throw new Error(`Expected date with year, got: ${result}`);
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
// Note: Timeout testing is tricky because it depends on network latency
|
|
156
|
-
// and the exact implementation. We skip it here - the timeout mechanism
|
|
157
|
-
// is tested at the unit level in command-validation.test.ts
|
|
158
|
-
// Test: Blocked command (if whitelist is restrictive)
|
|
159
|
-
if (config.commandWhitelist && config.commandWhitelist.length > 0) {
|
|
160
|
-
await runTest(`[${serverName}] Whitelist blocking`, async () => {
|
|
161
|
-
try {
|
|
162
|
-
await sshManager.executeCommand("rm -rf /", serverName, { timeout: 5000 });
|
|
163
|
-
throw new Error("Dangerous command should have been blocked");
|
|
164
|
-
}
|
|
165
|
-
catch (error) {
|
|
166
|
-
if (error instanceof Error && error.message.includes("blocked")) {
|
|
167
|
-
// Expected - command was blocked
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
if (error instanceof Error && error.message.includes("Dangerous command should have been blocked")) {
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
173
|
-
// Some other error blocking it - also acceptable
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
// Test: Docker (if available)
|
|
178
|
-
await runTest(`[${serverName}] docker ps (if allowed)`, async () => {
|
|
179
|
-
try {
|
|
180
|
-
const result = await sshManager.executeCommand("docker ps", serverName, { timeout: 10000 });
|
|
181
|
-
if (!result.includes("CONTAINER")) {
|
|
182
|
-
// Check if docker command not found (expected on some servers)
|
|
183
|
-
if (result.includes("command not found") || result.includes("not found") || result.includes("EXIT CODE: 127")) {
|
|
184
|
-
info("docker not installed on this server - skipped");
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
throw new Error(`Unexpected docker output: ${result.substring(0, 100)}`);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
catch (error) {
|
|
191
|
-
if (error instanceof Error && (error.message.includes("blocked") || error.message.includes("whitelist"))) {
|
|
192
|
-
info("docker ps not in whitelist - skipped");
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
// Also handle "command not found" errors thrown as exceptions
|
|
196
|
-
if (error instanceof Error && (error.message.includes("command not found") || error.message.includes("EXIT CODE: 127"))) {
|
|
197
|
-
info("docker not installed on this server - skipped");
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
throw error;
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
// Test: getServerConfig (for show-whitelist tool)
|
|
204
|
-
await runTest(`[${serverName}] getServerConfig returns config`, async () => {
|
|
205
|
-
const config = sshManager.getServerConfig(serverName);
|
|
206
|
-
if (!config) {
|
|
207
|
-
throw new Error("getServerConfig returned null");
|
|
208
|
-
}
|
|
209
|
-
if (config.host !== parsedArgs.configs[serverName].host) {
|
|
210
|
-
throw new Error("Config host mismatch");
|
|
211
|
-
}
|
|
212
|
-
if (!config.commandWhitelist || config.commandWhitelist.length === 0) {
|
|
213
|
-
info("No custom whitelist - using defaults");
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
info(`Whitelist has ${config.commandWhitelist.length} patterns`);
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
// Test: Streaming mode (executeCommandWithProgress)
|
|
220
|
-
await runTest(`[${serverName}] Streaming mode output`, async () => {
|
|
221
|
-
const chunks = [];
|
|
222
|
-
const result = await sshManager.executeCommandWithProgress("echo 'line1' && echo 'line2' && echo 'line3'", serverName, {
|
|
223
|
-
timeout: 10000,
|
|
224
|
-
onProgress: (chunk) => chunks.push(chunk),
|
|
225
|
-
});
|
|
226
|
-
if (!result.includes("line1") || !result.includes("line2") || !result.includes("line3")) {
|
|
227
|
-
throw new Error(`Expected 3 lines in output, got: ${result}`);
|
|
228
|
-
}
|
|
229
|
-
info(`Received ${chunks.length} progress chunks`);
|
|
230
|
-
});
|
|
231
|
-
// Test: list-servers tool (getAllServerInfos)
|
|
232
|
-
await runTest(`[${serverName}] getAllServerInfos returns data`, async () => {
|
|
233
|
-
const servers = sshManager.getAllServerInfos();
|
|
234
|
-
if (!Array.isArray(servers)) {
|
|
235
|
-
throw new Error("getAllServerInfos did not return an array");
|
|
236
|
-
}
|
|
237
|
-
const thisServer = servers.find(s => s.name === serverName);
|
|
238
|
-
if (!thisServer) {
|
|
239
|
-
throw new Error(`Server ${serverName} not found in getAllServerInfos`);
|
|
240
|
-
}
|
|
241
|
-
if (thisServer.host !== config.host) {
|
|
242
|
-
throw new Error(`Host mismatch: expected ${config.host}, got ${thisServer.host}`);
|
|
243
|
-
}
|
|
244
|
-
info(`Found ${servers.length} server(s) in list`);
|
|
245
|
-
});
|
|
246
|
-
// Test: Timeout behavior (short timeout should not hang forever)
|
|
247
|
-
await runTest(`[${serverName}] Short timeout behavior`, async () => {
|
|
248
|
-
const start = Date.now();
|
|
249
|
-
try {
|
|
250
|
-
// Use a very short timeout (100ms) - command should either complete or timeout quickly
|
|
251
|
-
await sshManager.executeCommand("sleep 0.05", serverName, { timeout: 500 });
|
|
252
|
-
// If we get here, the command completed before timeout (which is fine)
|
|
253
|
-
const elapsed = Date.now() - start;
|
|
254
|
-
info(`Command completed in ${elapsed}ms`);
|
|
255
|
-
}
|
|
256
|
-
catch (error) {
|
|
257
|
-
// Timeout error is also acceptable - what matters is it didn't hang
|
|
258
|
-
const elapsed = Date.now() - start;
|
|
259
|
-
if (elapsed > 5000) {
|
|
260
|
-
throw new Error(`Timeout took too long: ${elapsed}ms - should have been ~500ms`);
|
|
261
|
-
}
|
|
262
|
-
info(`Command timed out after ${elapsed}ms (expected behavior)`);
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
// Test: Upload and Download (if tmp directory is writable)
|
|
266
|
-
await runTest(`[${serverName}] Upload/Download file transfer`, async () => {
|
|
267
|
-
const fs = await import("fs");
|
|
268
|
-
const path = await import("path");
|
|
269
|
-
// Use a path within the project directory to avoid path traversal detection
|
|
270
|
-
const projectDir = path.resolve(__dirname, "../..");
|
|
271
|
-
const testContent = `handfree-ssh-mcp test file - ${Date.now()}`;
|
|
272
|
-
const localTestFile = path.join(projectDir, `ssh-mcp-test-${Date.now()}.txt`);
|
|
273
|
-
const remoteTestFile = `/tmp/ssh-mcp-test-${Date.now()}.txt`;
|
|
274
|
-
const downloadedFile = path.join(projectDir, `ssh-mcp-downloaded-${Date.now()}.txt`);
|
|
275
|
-
try {
|
|
276
|
-
// Write local test file
|
|
277
|
-
fs.writeFileSync(localTestFile, testContent);
|
|
278
|
-
info(`Created local test file: ${localTestFile}`);
|
|
279
|
-
// Upload
|
|
280
|
-
const uploadResult = await sshManager.upload(localTestFile, remoteTestFile, serverName);
|
|
281
|
-
info(`Upload result: ${uploadResult.substring(0, 50)}...`);
|
|
282
|
-
// Verify file exists on remote
|
|
283
|
-
const catResult = await sshManager.executeCommand(`cat ${remoteTestFile}`, serverName, { timeout: 5000 });
|
|
284
|
-
if (!catResult.includes(testContent)) {
|
|
285
|
-
throw new Error(`Uploaded file content mismatch. Expected "${testContent}", got "${catResult}"`);
|
|
286
|
-
}
|
|
287
|
-
info("Upload verified - remote file content matches");
|
|
288
|
-
// Download
|
|
289
|
-
const downloadResult = await sshManager.download(remoteTestFile, downloadedFile, serverName);
|
|
290
|
-
info(`Download result: ${downloadResult.substring(0, 50)}...`);
|
|
291
|
-
// Verify downloaded content
|
|
292
|
-
const downloadedContent = fs.readFileSync(downloadedFile, "utf-8");
|
|
293
|
-
if (downloadedContent !== testContent) {
|
|
294
|
-
throw new Error(`Downloaded file content mismatch. Expected "${testContent}", got "${downloadedContent}"`);
|
|
295
|
-
}
|
|
296
|
-
info("Download verified - local file content matches");
|
|
297
|
-
// Cleanup remote file
|
|
298
|
-
try {
|
|
299
|
-
await sshManager.executeCommand(`rm ${remoteTestFile}`, serverName, { timeout: 5000 });
|
|
300
|
-
}
|
|
301
|
-
catch {
|
|
302
|
-
// Ignore cleanup errors (rm might be blocked by whitelist)
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
finally {
|
|
306
|
-
// Cleanup local files
|
|
307
|
-
try {
|
|
308
|
-
if (fs.existsSync(localTestFile))
|
|
309
|
-
fs.unlinkSync(localTestFile);
|
|
310
|
-
if (fs.existsSync(downloadedFile))
|
|
311
|
-
fs.unlinkSync(downloadedFile);
|
|
312
|
-
}
|
|
313
|
-
catch {
|
|
314
|
-
// Ignore cleanup errors
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
// Summary
|
|
320
|
-
section("Test Summary");
|
|
321
|
-
const passed = results.filter(r => r.passed).length;
|
|
322
|
-
const failed = results.filter(r => !r.passed).length;
|
|
323
|
-
const total = results.length;
|
|
324
|
-
log(`\n Passed: ${passed}/${total}`, passed === total ? "green" : "yellow");
|
|
325
|
-
if (failed > 0) {
|
|
326
|
-
log(` Failed: ${failed}/${total}`, "red");
|
|
327
|
-
console.log();
|
|
328
|
-
for (const r of results.filter(r => !r.passed)) {
|
|
329
|
-
fail(`${r.name}`);
|
|
330
|
-
log(` ${r.error}`, "dim");
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
// Cleanup
|
|
334
|
-
section("Cleanup");
|
|
335
|
-
try {
|
|
336
|
-
sshManager.disconnect();
|
|
337
|
-
success("Disconnected all SSH connections");
|
|
338
|
-
}
|
|
339
|
-
catch (error) {
|
|
340
|
-
fail(`Disconnect error: ${error}`);
|
|
341
|
-
}
|
|
342
|
-
console.log();
|
|
343
|
-
process.exit(failed > 0 ? 1 : 0);
|
|
344
|
-
}
|
|
345
|
-
main().catch((error) => {
|
|
346
|
-
console.error("Fatal error:", error);
|
|
347
|
-
process.exit(1);
|
|
348
|
-
});
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert";
|
|
3
|
-
import { OutputCollector } from "../utils/output-collector.js";
|
|
4
|
-
describe("OutputCollector", () => {
|
|
5
|
-
it("returns everything when total bytes are below the cap", () => {
|
|
6
|
-
const c = new OutputCollector(64);
|
|
7
|
-
c.push("hello");
|
|
8
|
-
c.push(" world");
|
|
9
|
-
const snap = c.getSnapshot();
|
|
10
|
-
assert.strictEqual(snap.tail.toString("utf8"), "hello world");
|
|
11
|
-
assert.strictEqual(snap.totalBytes, 11);
|
|
12
|
-
assert.strictEqual(snap.droppedBytes, 0);
|
|
13
|
-
assert.strictEqual(snap.truncated, false);
|
|
14
|
-
});
|
|
15
|
-
it("drops bytes from the head when total exceeds the cap", () => {
|
|
16
|
-
const c = new OutputCollector(5);
|
|
17
|
-
c.push("abcdef"); // 6 bytes -> drop 1 from head, keep "bcdef"
|
|
18
|
-
const snap = c.getSnapshot();
|
|
19
|
-
assert.strictEqual(snap.tail.toString("utf8"), "bcdef");
|
|
20
|
-
assert.strictEqual(snap.totalBytes, 6);
|
|
21
|
-
assert.strictEqual(snap.droppedBytes, 1);
|
|
22
|
-
assert.strictEqual(snap.truncated, true);
|
|
23
|
-
});
|
|
24
|
-
it("keeps only the most recent bytes across multiple pushes", () => {
|
|
25
|
-
const c = new OutputCollector(4);
|
|
26
|
-
c.push("aaaa");
|
|
27
|
-
c.push("bbbb");
|
|
28
|
-
c.push("cccc"); // tail should be "cccc"
|
|
29
|
-
const snap = c.getSnapshot();
|
|
30
|
-
assert.strictEqual(snap.tail.toString("utf8"), "cccc");
|
|
31
|
-
assert.strictEqual(snap.totalBytes, 12);
|
|
32
|
-
assert.strictEqual(snap.droppedBytes, 8);
|
|
33
|
-
});
|
|
34
|
-
it("handles maxBytes=0 by dropping all input", () => {
|
|
35
|
-
const c = new OutputCollector(0);
|
|
36
|
-
c.push("hello");
|
|
37
|
-
const snap = c.getSnapshot();
|
|
38
|
-
assert.strictEqual(snap.tail.length, 0);
|
|
39
|
-
assert.strictEqual(snap.totalBytes, 5);
|
|
40
|
-
assert.strictEqual(snap.droppedBytes, 5);
|
|
41
|
-
assert.strictEqual(snap.truncated, true);
|
|
42
|
-
});
|
|
43
|
-
it("trims a partial chunk at the head when overshoot < chunk length", () => {
|
|
44
|
-
const c = new OutputCollector(3);
|
|
45
|
-
c.push("abcde"); // 5 bytes, overshoot 2 -> keep "cde"
|
|
46
|
-
const snap = c.getSnapshot();
|
|
47
|
-
assert.strictEqual(snap.tail.toString("utf8"), "cde");
|
|
48
|
-
assert.strictEqual(snap.droppedBytes, 2);
|
|
49
|
-
});
|
|
50
|
-
it("rejects negative or non-finite maxBytes", () => {
|
|
51
|
-
assert.throws(() => new OutputCollector(-1), /non-negative/);
|
|
52
|
-
assert.throws(() => new OutputCollector(Number.NaN), /non-negative/);
|
|
53
|
-
assert.throws(() => new OutputCollector(Number.POSITIVE_INFINITY), /finite/);
|
|
54
|
-
});
|
|
55
|
-
it("ignores empty pushes", () => {
|
|
56
|
-
const c = new OutputCollector(10);
|
|
57
|
-
c.push("");
|
|
58
|
-
c.push(Buffer.alloc(0));
|
|
59
|
-
const snap = c.getSnapshot();
|
|
60
|
-
assert.strictEqual(snap.totalBytes, 0);
|
|
61
|
-
assert.strictEqual(snap.droppedBytes, 0);
|
|
62
|
-
assert.strictEqual(snap.tail.length, 0);
|
|
63
|
-
});
|
|
64
|
-
it("accepts Buffers and strings interchangeably", () => {
|
|
65
|
-
const c = new OutputCollector(20);
|
|
66
|
-
c.push(Buffer.from([0x68, 0x69])); // "hi"
|
|
67
|
-
c.push(" world");
|
|
68
|
-
assert.strictEqual(c.getSnapshot().tail.toString("utf8"), "hi world");
|
|
69
|
-
});
|
|
70
|
-
});
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
-
import assert from "node:assert";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { OutputLogWriter } from "../utils/output-log-writer.js";
|
|
7
|
-
describe("OutputLogWriter", () => {
|
|
8
|
-
let root;
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
root = fs.mkdtempSync(path.join(os.tmpdir(), "handfree-ssh-mcp-logwriter-"));
|
|
11
|
-
});
|
|
12
|
-
afterEach(() => {
|
|
13
|
-
if (fs.existsSync(root)) {
|
|
14
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
15
|
-
}
|
|
16
|
-
});
|
|
17
|
-
it("writes a log under <root>/<server>/<user>/<file>.log with META / STDOUT / STDERR / END markers", () => {
|
|
18
|
-
const w = new OutputLogWriter({
|
|
19
|
-
rootDir: root,
|
|
20
|
-
serverName: "dev",
|
|
21
|
-
username: "alice",
|
|
22
|
-
command: "echo hi",
|
|
23
|
-
});
|
|
24
|
-
w.appendStdout("hi\n");
|
|
25
|
-
w.appendStderr("warn\n");
|
|
26
|
-
w.close({ exitCode: 0, durationMs: 42 });
|
|
27
|
-
const filePath = w.getPath();
|
|
28
|
-
assert.ok(filePath.includes(path.join("dev", "alice")), `got ${filePath}`);
|
|
29
|
-
assert.ok(fs.existsSync(filePath));
|
|
30
|
-
const content = fs.readFileSync(filePath, "utf8");
|
|
31
|
-
assert.match(content, /^=== META ===\n/);
|
|
32
|
-
assert.match(content, /\nserver: dev\n/);
|
|
33
|
-
assert.match(content, /\nuser: alice\n/);
|
|
34
|
-
assert.match(content, /\ncommand: echo hi\n/);
|
|
35
|
-
assert.match(content, /\n=== STDOUT ===\nhi\n\n=== STDERR ===\nwarn\n\n=== END ===\n/);
|
|
36
|
-
assert.match(content, /\nexitCode: 0\n/);
|
|
37
|
-
assert.match(content, /\ndurationMs: 42\n/);
|
|
38
|
-
assert.match(content, /\nstdoutBytes: 3\n/);
|
|
39
|
-
assert.match(content, /\nstderrBytes: 5\n/);
|
|
40
|
-
});
|
|
41
|
-
it("creates server/user directories if they do not exist", () => {
|
|
42
|
-
const w = new OutputLogWriter({
|
|
43
|
-
rootDir: path.join(root, "deep", "nested"),
|
|
44
|
-
serverName: "prod",
|
|
45
|
-
username: "bob",
|
|
46
|
-
command: "ls",
|
|
47
|
-
});
|
|
48
|
-
w.close({ exitCode: 0, durationMs: 0 });
|
|
49
|
-
assert.ok(fs.existsSync(path.join(root, "deep", "nested", "prod", "bob")));
|
|
50
|
-
});
|
|
51
|
-
it("sanitizes unsafe characters in server/user names", () => {
|
|
52
|
-
const w = new OutputLogWriter({
|
|
53
|
-
rootDir: root,
|
|
54
|
-
serverName: "../escape",
|
|
55
|
-
username: "name/with:bad",
|
|
56
|
-
command: "ls",
|
|
57
|
-
});
|
|
58
|
-
w.close({ exitCode: 0, durationMs: 0 });
|
|
59
|
-
const p = w.getPath();
|
|
60
|
-
// Must stay inside root and not contain raw separators / colons in the leaf segments.
|
|
61
|
-
assert.ok(p.startsWith(root), `path escaped root: ${p}`);
|
|
62
|
-
const rel = path.relative(root, p).split(path.sep);
|
|
63
|
-
// rel = [serverSeg, userSeg, fileName]
|
|
64
|
-
assert.strictEqual(rel.length, 3);
|
|
65
|
-
assert.doesNotMatch(rel[0], /[/:]/);
|
|
66
|
-
assert.doesNotMatch(rel[1], /[/:]/);
|
|
67
|
-
assert.notStrictEqual(rel[0], "..");
|
|
68
|
-
});
|
|
69
|
-
it("close() is idempotent", () => {
|
|
70
|
-
const w = new OutputLogWriter({
|
|
71
|
-
rootDir: root,
|
|
72
|
-
serverName: "dev",
|
|
73
|
-
username: "alice",
|
|
74
|
-
command: "ls",
|
|
75
|
-
});
|
|
76
|
-
w.appendStdout("hello");
|
|
77
|
-
w.close({ exitCode: 0, durationMs: 1 });
|
|
78
|
-
const before = fs.readFileSync(w.getPath(), "utf8");
|
|
79
|
-
// Second close must be a no-op; appending more should not change the file.
|
|
80
|
-
w.appendStdout(" world");
|
|
81
|
-
w.close({ exitCode: 99, durationMs: 999 });
|
|
82
|
-
const after = fs.readFileSync(w.getPath(), "utf8");
|
|
83
|
-
assert.strictEqual(before, after);
|
|
84
|
-
});
|
|
85
|
-
it("reports a null exitCode when the remote was signaled", () => {
|
|
86
|
-
const w = new OutputLogWriter({
|
|
87
|
-
rootDir: root,
|
|
88
|
-
serverName: "dev",
|
|
89
|
-
username: "alice",
|
|
90
|
-
command: "sleep 9999",
|
|
91
|
-
});
|
|
92
|
-
w.close({ exitCode: null, durationMs: 0 });
|
|
93
|
-
const content = fs.readFileSync(w.getPath(), "utf8");
|
|
94
|
-
assert.match(content, /\nexitCode: null\n/);
|
|
95
|
-
});
|
|
96
|
-
it("streams large stdout through a .stdout.part temp file and cleans it up on close", () => {
|
|
97
|
-
const w = new OutputLogWriter({
|
|
98
|
-
rootDir: root,
|
|
99
|
-
serverName: "dev",
|
|
100
|
-
username: "alice",
|
|
101
|
-
command: "yes",
|
|
102
|
-
});
|
|
103
|
-
// 2 MiB of stdout in many small writes — should never live fully in RAM.
|
|
104
|
-
const chunk = Buffer.alloc(64 * 1024, 0x41); // 64 KiB of 'A'
|
|
105
|
-
for (let i = 0; i < 32; i++) {
|
|
106
|
-
w.appendStdout(chunk);
|
|
107
|
-
}
|
|
108
|
-
const partPath = `${w.getPath()}.stdout.part`;
|
|
109
|
-
assert.ok(fs.existsSync(partPath), "stdout part file must exist while streaming");
|
|
110
|
-
const partSize = fs.statSync(partPath).size;
|
|
111
|
-
assert.strictEqual(partSize, 32 * 64 * 1024);
|
|
112
|
-
w.close({ exitCode: 0, durationMs: 1 });
|
|
113
|
-
// Part file must be cleaned up.
|
|
114
|
-
assert.ok(!fs.existsSync(partPath), "stdout part file must be removed after close");
|
|
115
|
-
// Final file must contain all the stdout bytes intact.
|
|
116
|
-
const final = fs.readFileSync(w.getPath());
|
|
117
|
-
const stdoutStart = final.indexOf("=== STDOUT ===\n");
|
|
118
|
-
const stderrStart = final.indexOf("\n=== STDERR ===\n");
|
|
119
|
-
assert.ok(stdoutStart > 0 && stderrStart > stdoutStart);
|
|
120
|
-
const stdoutBody = final.subarray(stdoutStart + "=== STDOUT ===\n".length, stderrStart);
|
|
121
|
-
assert.strictEqual(stdoutBody.length, 32 * 64 * 1024);
|
|
122
|
-
// Spot check: every byte must be 'A'.
|
|
123
|
-
assert.strictEqual(stdoutBody[0], 0x41);
|
|
124
|
-
assert.strictEqual(stdoutBody[stdoutBody.length - 1], 0x41);
|
|
125
|
-
const content = final.toString("utf8");
|
|
126
|
-
assert.match(content, /\nstdoutBytes: 2097152\n/);
|
|
127
|
-
assert.match(content, /\nstderrBytes: 0\n/);
|
|
128
|
-
});
|
|
129
|
-
it("does not create part files when nothing is appended", () => {
|
|
130
|
-
const w = new OutputLogWriter({
|
|
131
|
-
rootDir: root,
|
|
132
|
-
serverName: "dev",
|
|
133
|
-
username: "alice",
|
|
134
|
-
command: "true",
|
|
135
|
-
});
|
|
136
|
-
w.close({ exitCode: 0, durationMs: 0 });
|
|
137
|
-
assert.ok(fs.existsSync(w.getPath()));
|
|
138
|
-
assert.ok(!fs.existsSync(`${w.getPath()}.stdout.part`));
|
|
139
|
-
assert.ok(!fs.existsSync(`${w.getPath()}.stderr.part`));
|
|
140
|
-
const content = fs.readFileSync(w.getPath(), "utf8");
|
|
141
|
-
// Empty stdout/stderr sections still produce the markers and zero byte counts.
|
|
142
|
-
assert.match(content, /\n=== STDOUT ===\n\n=== STDERR ===\n\n=== END ===\n/);
|
|
143
|
-
assert.match(content, /\nstdoutBytes: 0\n/);
|
|
144
|
-
assert.match(content, /\nstderrBytes: 0\n/);
|
|
145
|
-
});
|
|
146
|
-
it("preserves byte order across interleaved stdout/stderr appends", () => {
|
|
147
|
-
const w = new OutputLogWriter({
|
|
148
|
-
rootDir: root,
|
|
149
|
-
serverName: "dev",
|
|
150
|
-
username: "alice",
|
|
151
|
-
command: "noisy",
|
|
152
|
-
});
|
|
153
|
-
// Interleave to make sure we don't accidentally mix streams.
|
|
154
|
-
w.appendStdout("out-1\n");
|
|
155
|
-
w.appendStderr("err-1\n");
|
|
156
|
-
w.appendStdout("out-2\n");
|
|
157
|
-
w.appendStderr("err-2\n");
|
|
158
|
-
w.appendStdout("out-3\n");
|
|
159
|
-
w.close({ exitCode: 0, durationMs: 1 });
|
|
160
|
-
const content = fs.readFileSync(w.getPath(), "utf8");
|
|
161
|
-
const stdoutMatch = content.match(/=== STDOUT ===\n([\s\S]*?)\n=== STDERR ===/);
|
|
162
|
-
const stderrMatch = content.match(/=== STDERR ===\n([\s\S]*?)\n=== END ===/);
|
|
163
|
-
assert.ok(stdoutMatch && stderrMatch);
|
|
164
|
-
assert.strictEqual(stdoutMatch[1], "out-1\nout-2\nout-3\n");
|
|
165
|
-
assert.strictEqual(stderrMatch[1], "err-1\nerr-2\n");
|
|
166
|
-
});
|
|
167
|
-
it("ignores appends after close()", () => {
|
|
168
|
-
const w = new OutputLogWriter({
|
|
169
|
-
rootDir: root,
|
|
170
|
-
serverName: "dev",
|
|
171
|
-
username: "alice",
|
|
172
|
-
command: "ls",
|
|
173
|
-
});
|
|
174
|
-
w.appendStdout("before\n");
|
|
175
|
-
w.close({ exitCode: 0, durationMs: 1 });
|
|
176
|
-
const before = fs.readFileSync(w.getPath(), "utf8");
|
|
177
|
-
w.appendStdout("after\n");
|
|
178
|
-
w.appendStderr("after-err\n");
|
|
179
|
-
const after = fs.readFileSync(w.getPath(), "utf8");
|
|
180
|
-
assert.strictEqual(before, after);
|
|
181
|
-
});
|
|
182
|
-
it("file names sort chronologically and avoid collisions for the same start time", () => {
|
|
183
|
-
const fixedDate = new Date("2026-05-16T02:15:30.000Z");
|
|
184
|
-
const a = new OutputLogWriter({
|
|
185
|
-
rootDir: root,
|
|
186
|
-
serverName: "dev",
|
|
187
|
-
username: "alice",
|
|
188
|
-
command: "ls",
|
|
189
|
-
startedAt: fixedDate,
|
|
190
|
-
});
|
|
191
|
-
const b = new OutputLogWriter({
|
|
192
|
-
rootDir: root,
|
|
193
|
-
serverName: "dev",
|
|
194
|
-
username: "alice",
|
|
195
|
-
command: "ls",
|
|
196
|
-
startedAt: fixedDate,
|
|
197
|
-
});
|
|
198
|
-
// Same timestamp prefix, but the random suffix makes them distinct.
|
|
199
|
-
const baseA = path.basename(a.getPath());
|
|
200
|
-
const baseB = path.basename(b.getPath());
|
|
201
|
-
assert.match(baseA, /^20260516T021530Z-\d+-[0-9a-f]{8}\.log$/);
|
|
202
|
-
assert.match(baseB, /^20260516T021530Z-\d+-[0-9a-f]{8}\.log$/);
|
|
203
|
-
assert.notStrictEqual(baseA, baseB);
|
|
204
|
-
});
|
|
205
|
-
});
|