@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,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Whitebox tests for the recent code-review fixes:
|
|
3
|
-
*
|
|
4
|
-
* f1: connect() in-flight dedup (no duplicate SSH clients on parallel calls)
|
|
5
|
-
* f2: --pre-connect CLI flag parsing
|
|
6
|
-
* f5: isPathInSafeDirectory boundary check
|
|
7
|
-
* default-name behavior: empty in multi-server mode, set in single-server mode
|
|
8
|
-
*
|
|
9
|
-
* These reach into SSHConnectionManager via `as any` to exercise private
|
|
10
|
-
* surfaces directly, matching the style of command-validation.test.ts.
|
|
11
|
-
*/
|
|
12
|
-
import { afterEach, describe, it } from "node:test";
|
|
13
|
-
import assert from "node:assert";
|
|
14
|
-
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
|
15
|
-
import { getPreConnectFlag } from "../config/config-loader.js";
|
|
16
|
-
const baseConfig = (overrides = {}) => ({
|
|
17
|
-
name: "dev",
|
|
18
|
-
host: "127.0.0.1",
|
|
19
|
-
port: 22,
|
|
20
|
-
username: "root",
|
|
21
|
-
password: "test-password",
|
|
22
|
-
safeDirectory: "/root",
|
|
23
|
-
...overrides,
|
|
24
|
-
});
|
|
25
|
-
describe("getPreConnectFlag", () => {
|
|
26
|
-
it("returns true when --pre-connect is present", () => {
|
|
27
|
-
assert.strictEqual(getPreConnectFlag(["--config", "x.yaml", "--pre-connect", "--enable-servers", "a"]), true);
|
|
28
|
-
});
|
|
29
|
-
it("returns false when --pre-connect is absent", () => {
|
|
30
|
-
assert.strictEqual(getPreConnectFlag(["--config", "x.yaml", "--enable-servers", "a"]), false);
|
|
31
|
-
});
|
|
32
|
-
it("returns false for empty args", () => {
|
|
33
|
-
assert.strictEqual(getPreConnectFlag([]), false);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
/**
|
|
37
|
-
* Reset only the state we care about, without invoking disconnect() (which
|
|
38
|
-
* assumes real ssh2 clients with an .end() method). Tests in this file stub
|
|
39
|
-
* doConnect and store sentinel objects in the clients map.
|
|
40
|
-
*/
|
|
41
|
-
function resetManagerState(manager) {
|
|
42
|
-
manager.connecting?.clear?.();
|
|
43
|
-
manager.connected?.clear?.();
|
|
44
|
-
manager.clients?.clear?.();
|
|
45
|
-
manager.setConfig({}, undefined);
|
|
46
|
-
}
|
|
47
|
-
describe("SSHConnectionManager.connect() in-flight dedup", () => {
|
|
48
|
-
const manager = SSHConnectionManager.getInstance();
|
|
49
|
-
afterEach(() => {
|
|
50
|
-
resetManagerState(manager);
|
|
51
|
-
});
|
|
52
|
-
it("collapses concurrent connect() calls for the same server into one doConnect", async () => {
|
|
53
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
54
|
-
let doConnectCalls = 0;
|
|
55
|
-
const originalDoConnect = manager.doConnect;
|
|
56
|
-
manager.doConnect = async (key) => {
|
|
57
|
-
doConnectCalls += 1;
|
|
58
|
-
// Tiny tick so all callers are queued on the same promise.
|
|
59
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
60
|
-
manager.connected.set(key, true);
|
|
61
|
-
manager.clients.set(key, { fake: true });
|
|
62
|
-
};
|
|
63
|
-
try {
|
|
64
|
-
await Promise.all([
|
|
65
|
-
manager.connect("dev"),
|
|
66
|
-
manager.connect("dev"),
|
|
67
|
-
manager.connect("dev"),
|
|
68
|
-
manager.connect("dev"),
|
|
69
|
-
]);
|
|
70
|
-
assert.strictEqual(doConnectCalls, 1, "doConnect must run exactly once for concurrent calls");
|
|
71
|
-
assert.strictEqual(manager.clients.size, 1, "exactly one SSH client must be stored");
|
|
72
|
-
}
|
|
73
|
-
finally {
|
|
74
|
-
manager.doConnect = originalDoConnect;
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
it("clears the in-flight entry after success so the next connect can reconnect", async () => {
|
|
78
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
79
|
-
let doConnectCalls = 0;
|
|
80
|
-
const originalDoConnect = manager.doConnect;
|
|
81
|
-
manager.doConnect = async (key) => {
|
|
82
|
-
doConnectCalls += 1;
|
|
83
|
-
manager.connected.set(key, true);
|
|
84
|
-
manager.clients.set(key, { fake: true, n: doConnectCalls });
|
|
85
|
-
};
|
|
86
|
-
try {
|
|
87
|
-
await manager.connect("dev");
|
|
88
|
-
// Mark disconnected to force a second real connect.
|
|
89
|
-
manager.connected.set("dev", false);
|
|
90
|
-
manager.clients.delete("dev");
|
|
91
|
-
await manager.connect("dev");
|
|
92
|
-
assert.strictEqual(doConnectCalls, 2);
|
|
93
|
-
assert.strictEqual(manager.connecting.size, 0, "in-flight map must be empty after settle");
|
|
94
|
-
}
|
|
95
|
-
finally {
|
|
96
|
-
manager.doConnect = originalDoConnect;
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
it("clears the in-flight entry on failure so the next connect can retry", async () => {
|
|
100
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
101
|
-
let doConnectCalls = 0;
|
|
102
|
-
const originalDoConnect = manager.doConnect;
|
|
103
|
-
manager.doConnect = async (_key) => {
|
|
104
|
-
doConnectCalls += 1;
|
|
105
|
-
throw new Error("simulated connect failure");
|
|
106
|
-
};
|
|
107
|
-
try {
|
|
108
|
-
await assert.rejects(manager.connect("dev"));
|
|
109
|
-
assert.strictEqual(manager.connecting.size, 0, "in-flight map must be cleared after rejection");
|
|
110
|
-
// Second call must attempt doConnect again, not return the stale rejected promise.
|
|
111
|
-
await assert.rejects(manager.connect("dev"));
|
|
112
|
-
assert.strictEqual(doConnectCalls, 2);
|
|
113
|
-
}
|
|
114
|
-
finally {
|
|
115
|
-
manager.doConnect = originalDoConnect;
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
it("returns immediately for an already-connected server without invoking doConnect", async () => {
|
|
119
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
120
|
-
manager.connected.set("dev", true);
|
|
121
|
-
manager.clients.set("dev", { fake: true });
|
|
122
|
-
let doConnectCalls = 0;
|
|
123
|
-
const originalDoConnect = manager.doConnect;
|
|
124
|
-
manager.doConnect = async () => {
|
|
125
|
-
doConnectCalls += 1;
|
|
126
|
-
};
|
|
127
|
-
try {
|
|
128
|
-
await manager.connect("dev");
|
|
129
|
-
await manager.connect("dev");
|
|
130
|
-
assert.strictEqual(doConnectCalls, 0);
|
|
131
|
-
}
|
|
132
|
-
finally {
|
|
133
|
-
manager.doConnect = originalDoConnect;
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
it("invalidates pending clients when hot-reload changes connection fields", () => {
|
|
137
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
138
|
-
const pendingClient = {
|
|
139
|
-
ended: false,
|
|
140
|
-
end() {
|
|
141
|
-
this.ended = true;
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
manager.pendingClients.set("dev", pendingClient);
|
|
145
|
-
manager.connecting.set("dev", Promise.resolve());
|
|
146
|
-
const generationBefore = manager.connectionGenerations.get("dev") ?? 0;
|
|
147
|
-
manager.replaceConfig({ dev: baseConfig({ host: "127.0.0.2" }) }, ["dev"]);
|
|
148
|
-
assert.strictEqual(pendingClient.ended, true);
|
|
149
|
-
assert.strictEqual(manager.pendingClients.has("dev"), false);
|
|
150
|
-
assert.strictEqual(manager.connecting.has("dev"), false);
|
|
151
|
-
assert.strictEqual(manager.connectionGenerations.get("dev"), generationBefore + 1);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
describe("SSHConnectionManager default-server semantics", () => {
|
|
155
|
-
const manager = SSHConnectionManager.getInstance();
|
|
156
|
-
afterEach(() => {
|
|
157
|
-
resetManagerState(manager);
|
|
158
|
-
});
|
|
159
|
-
it("sets defaultName when exactly one server is enabled", () => {
|
|
160
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
161
|
-
assert.strictEqual(manager.defaultName, "dev");
|
|
162
|
-
});
|
|
163
|
-
it("clears defaultName when multiple servers are enabled", () => {
|
|
164
|
-
manager.setConfig({
|
|
165
|
-
dev: baseConfig({ name: "dev" }),
|
|
166
|
-
prod: baseConfig({ name: "prod", host: "127.0.0.2" }),
|
|
167
|
-
}, ["dev", "prod"]);
|
|
168
|
-
assert.strictEqual(manager.defaultName, "");
|
|
169
|
-
});
|
|
170
|
-
it("isMultiServer() reflects the enabled count", () => {
|
|
171
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
172
|
-
assert.strictEqual(manager.isMultiServer(), false);
|
|
173
|
-
manager.setConfig({
|
|
174
|
-
dev: baseConfig({ name: "dev" }),
|
|
175
|
-
prod: baseConfig({ name: "prod", host: "127.0.0.2" }),
|
|
176
|
-
}, ["dev", "prod"]);
|
|
177
|
-
assert.strictEqual(manager.isMultiServer(), true);
|
|
178
|
-
});
|
|
179
|
-
it("resolveServer() returns the only server in single-server mode without connectionName", () => {
|
|
180
|
-
manager.setConfig({ dev: baseConfig() }, ["dev"]);
|
|
181
|
-
assert.strictEqual(manager.resolveServer(), "dev");
|
|
182
|
-
assert.strictEqual(manager.resolveServer("dev"), "dev");
|
|
183
|
-
});
|
|
184
|
-
it("resolveServer() throws in multi-server mode when connectionName is missing", () => {
|
|
185
|
-
manager.setConfig({
|
|
186
|
-
dev: baseConfig({ name: "dev" }),
|
|
187
|
-
prod: baseConfig({ name: "prod", host: "127.0.0.2" }),
|
|
188
|
-
}, ["dev", "prod"]);
|
|
189
|
-
assert.throws(() => manager.resolveServer(), (err) => err?.code === "INVALID_CONFIGURATION" && /must specify connectionName/.test(err.message));
|
|
190
|
-
});
|
|
191
|
-
it("resolveServer() accepts an explicit name in multi-server mode", () => {
|
|
192
|
-
manager.setConfig({
|
|
193
|
-
dev: baseConfig({ name: "dev" }),
|
|
194
|
-
prod: baseConfig({ name: "prod", host: "127.0.0.2" }),
|
|
195
|
-
}, ["dev", "prod"]);
|
|
196
|
-
assert.strictEqual(manager.resolveServer("prod"), "prod");
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
describe("isPathInSafeDirectory boundary check", () => {
|
|
200
|
-
const manager = SSHConnectionManager.getInstance();
|
|
201
|
-
// The method is private; bind it so we don't depend on a config.
|
|
202
|
-
const check = (filePath, safeDir) => manager.isPathInSafeDirectory.call(manager, filePath, safeDir);
|
|
203
|
-
it("accepts the safe directory itself", () => {
|
|
204
|
-
assert.strictEqual(check("/home/alice", "/home/alice"), true);
|
|
205
|
-
});
|
|
206
|
-
it("accepts paths directly inside the safe directory", () => {
|
|
207
|
-
assert.strictEqual(check("/home/alice/work/file.txt", "/home/alice"), true);
|
|
208
|
-
});
|
|
209
|
-
it("rejects sibling directories that share a prefix", () => {
|
|
210
|
-
// The headline bug: /home/alice-evil must NOT match safeDir /home/alice.
|
|
211
|
-
assert.strictEqual(check("/home/alice-evil/secret", "/home/alice"), false);
|
|
212
|
-
});
|
|
213
|
-
it("rejects a path with the safe dir as a substring but not a parent", () => {
|
|
214
|
-
assert.strictEqual(check("/var/home/alice/file", "/home/alice"), false);
|
|
215
|
-
});
|
|
216
|
-
it("rejects '..' escape attempts that normalize outside the safe dir", () => {
|
|
217
|
-
assert.strictEqual(check("/home/alice/../bob/file", "/home/alice"), false);
|
|
218
|
-
});
|
|
219
|
-
it("collapses '..' inside the safe dir and still accepts the result", () => {
|
|
220
|
-
assert.strictEqual(check("/home/alice/work/../code/file", "/home/alice"), true);
|
|
221
|
-
});
|
|
222
|
-
it("treats safeDir = / as a special case that accepts everything absolute", () => {
|
|
223
|
-
assert.strictEqual(check("/etc/passwd", "/"), true);
|
|
224
|
-
assert.strictEqual(check("/", "/"), true);
|
|
225
|
-
});
|
|
226
|
-
it("handles redundant slashes inside the path", () => {
|
|
227
|
-
assert.strictEqual(check("/home//alice///work/file", "/home/alice"), true);
|
|
228
|
-
});
|
|
229
|
-
});
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert";
|
|
3
|
-
import { ToolError, formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
|
|
4
|
-
describe("ToolError helpers", () => {
|
|
5
|
-
it("should preserve ToolError instances and codes", () => {
|
|
6
|
-
const original = new ToolError("COMMAND_TIMEOUT", "timed out", false);
|
|
7
|
-
const converted = toToolError(original, "UNKNOWN_ERROR");
|
|
8
|
-
assert.strictEqual(converted, original);
|
|
9
|
-
assert.strictEqual(converted.code, "COMMAND_TIMEOUT");
|
|
10
|
-
});
|
|
11
|
-
it("should format structured tool errors as JSON", () => {
|
|
12
|
-
const formatted = formatToolErrorResponse(new ToolError("LOCAL_PATH_NOT_ALLOWED", "blocked path", false));
|
|
13
|
-
const parsed = JSON.parse(formatted);
|
|
14
|
-
assert.deepStrictEqual(parsed, {
|
|
15
|
-
code: "LOCAL_PATH_NOT_ALLOWED",
|
|
16
|
-
message: "blocked path",
|
|
17
|
-
retriable: false,
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
it("should use the provided fallback code for generic errors", () => {
|
|
21
|
-
const converted = toToolError(new Error("socket broke"), "SSH_CONNECTION_FAILED");
|
|
22
|
-
assert.strictEqual(converted.code, "SSH_CONNECTION_FAILED");
|
|
23
|
-
assert.strictEqual(converted.message, "socket broke");
|
|
24
|
-
assert.strictEqual(converted.retriable, false);
|
|
25
|
-
});
|
|
26
|
-
});
|