@xynogen/pix-ssh 0.3.3 → 0.3.4
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/package.json +2 -1
- package/src/lib.test.ts +0 -320
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-ssh",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
|
+
"!src/**/*.test.*",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
package/src/lib.test.ts
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import registerSsh from "./index.ts";
|
|
4
|
-
import {
|
|
5
|
-
baseScpArgs,
|
|
6
|
-
baseSshArgs,
|
|
7
|
-
commandEscalatesPrivilege,
|
|
8
|
-
controlPathFor,
|
|
9
|
-
detectSshFailure,
|
|
10
|
-
detectSudoFailure,
|
|
11
|
-
filterSudoPrompt,
|
|
12
|
-
hostApproved,
|
|
13
|
-
hostTarget,
|
|
14
|
-
isUnreachable,
|
|
15
|
-
parseHost,
|
|
16
|
-
parseSshConfig,
|
|
17
|
-
remoteCommand,
|
|
18
|
-
remoteTransferPath,
|
|
19
|
-
shellQuote,
|
|
20
|
-
transferApprovalDecision,
|
|
21
|
-
transferArgs,
|
|
22
|
-
truncate,
|
|
23
|
-
} from "./lib.ts";
|
|
24
|
-
|
|
25
|
-
describe("parseHost", () => {
|
|
26
|
-
it("parses bare host", () => {
|
|
27
|
-
expect(parseHost("example.com")).toEqual({ host: "example.com" });
|
|
28
|
-
});
|
|
29
|
-
it("parses user@host", () => {
|
|
30
|
-
expect(parseHost("deploy@10.0.0.5")).toEqual({ user: "deploy", host: "10.0.0.5" });
|
|
31
|
-
});
|
|
32
|
-
it("parses user@host:port", () => {
|
|
33
|
-
expect(parseHost("deploy@10.0.0.5:2222")).toEqual({
|
|
34
|
-
user: "deploy",
|
|
35
|
-
host: "10.0.0.5",
|
|
36
|
-
port: 2222,
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
it("parses bracketed IPv6 with port", () => {
|
|
40
|
-
expect(parseHost("root@[::1]:22")).toEqual({ user: "root", host: "::1", port: 22 });
|
|
41
|
-
});
|
|
42
|
-
it("leaves bare IPv6 (multiple colons) as host", () => {
|
|
43
|
-
expect(parseHost("fe80::1")).toEqual({ host: "fe80::1" });
|
|
44
|
-
});
|
|
45
|
-
it("rejects empty host", () => {
|
|
46
|
-
expect(() => parseHost(" ")).toThrow();
|
|
47
|
-
expect(() => parseHost("user@")).toThrow();
|
|
48
|
-
});
|
|
49
|
-
it("rejects invalid port", () => {
|
|
50
|
-
expect(() => parseHost("h:0")).toThrow();
|
|
51
|
-
expect(() => parseHost("h:70000")).toThrow();
|
|
52
|
-
expect(() => parseHost("h:abc")).toThrow();
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe("parseSshConfig", () => {
|
|
57
|
-
it("reads effective user, hostname, and port from ssh -G output", () => {
|
|
58
|
-
expect(parseSshConfig("host orin\nuser jetson\nhostname 10.10.21.251\nport 2222\n")).toEqual({
|
|
59
|
-
user: "jetson",
|
|
60
|
-
host: "10.10.21.251",
|
|
61
|
-
port: 2222,
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("rejects incomplete ssh -G output", () => {
|
|
66
|
-
expect(parseSshConfig("hostname 10.10.21.251\n")).toBeUndefined();
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("hostTarget", () => {
|
|
71
|
-
it("joins user and host", () => {
|
|
72
|
-
expect(hostTarget({ user: "a", host: "b" })).toBe("a@b");
|
|
73
|
-
expect(hostTarget({ host: "b" })).toBe("b");
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("controlPathFor", () => {
|
|
78
|
-
it("is stable per host+user+port and differs across hosts", () => {
|
|
79
|
-
const a = controlPathFor({ user: "u", host: "h", port: 22 });
|
|
80
|
-
expect(a).toBe(controlPathFor({ user: "u", host: "h", port: 22 }));
|
|
81
|
-
expect(a).not.toBe(controlPathFor({ user: "u", host: "h", port: 23 }));
|
|
82
|
-
expect(a).not.toBe(controlPathFor({ user: "x", host: "h", port: 22 }));
|
|
83
|
-
expect(a).toContain("pix-ssh-");
|
|
84
|
-
expect(a.endsWith(".sock")).toBe(true);
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe("baseSshArgs", () => {
|
|
89
|
-
it("enables multiplexing and includes the control path", () => {
|
|
90
|
-
const args = baseSshArgs({ host: "h" }, "/tmp/x.sock");
|
|
91
|
-
expect(args).toContain("ControlMaster=auto");
|
|
92
|
-
expect(args).toContain("ControlPath=/tmp/x.sock");
|
|
93
|
-
expect(args.some((a) => a.startsWith("ControlPersist="))).toBe(true);
|
|
94
|
-
});
|
|
95
|
-
it("adds -p only when a port is set", () => {
|
|
96
|
-
expect(baseSshArgs({ host: "h" }, "s")).not.toContain("-p");
|
|
97
|
-
const withPort = baseSshArgs({ host: "h", port: 2222 }, "s");
|
|
98
|
-
expect(withPort).toContain("-p");
|
|
99
|
-
expect(withPort).toContain("2222");
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe("transfer unattended approval", () => {
|
|
104
|
-
it("treats transfer as warning-level in AFK and YOLO", () => {
|
|
105
|
-
expect(transferApprovalDecision("off", false)).toBe("ask");
|
|
106
|
-
expect(transferApprovalDecision("afk", false)).toBe("allow");
|
|
107
|
-
expect(transferApprovalDecision("yolo", false)).toBe("allow");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("denies unattended transfer when a login password is missing", () => {
|
|
111
|
-
expect(transferApprovalDecision("afk", true)).toBe("deny");
|
|
112
|
-
expect(transferApprovalDecision("yolo", true)).toBe("deny");
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe("SCP transfer arguments", () => {
|
|
117
|
-
it("builds upload and download endpoints", () => {
|
|
118
|
-
const spec = { user: "deploy", host: "example.com", port: 2222 };
|
|
119
|
-
expect(remoteTransferPath(spec, "/srv/app file")).toBe("deploy@example.com:/srv/app file");
|
|
120
|
-
expect(transferArgs(spec, "upload", "./build", "/srv/app")).toEqual([
|
|
121
|
-
"./build",
|
|
122
|
-
"deploy@example.com:/srv/app",
|
|
123
|
-
]);
|
|
124
|
-
expect(transferArgs(spec, "download", "/var/log/app.log", "./app.log")).toEqual([
|
|
125
|
-
"deploy@example.com:/var/log/app.log",
|
|
126
|
-
"./app.log",
|
|
127
|
-
]);
|
|
128
|
-
expect(baseScpArgs(spec, "/tmp/control.sock", true)).toEqual([
|
|
129
|
-
"-o",
|
|
130
|
-
"ControlMaster=auto",
|
|
131
|
-
"-o",
|
|
132
|
-
"ControlPath=/tmp/control.sock",
|
|
133
|
-
"-o",
|
|
134
|
-
"ControlPersist=120",
|
|
135
|
-
"-o",
|
|
136
|
-
"ConnectTimeout=10",
|
|
137
|
-
"-o",
|
|
138
|
-
"StrictHostKeyChecking=accept-new",
|
|
139
|
-
"-P",
|
|
140
|
-
"2222",
|
|
141
|
-
"-r",
|
|
142
|
-
]);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("brackets IPv6 hosts for SCP remote-path syntax", () => {
|
|
146
|
-
expect(remoteTransferPath({ user: "root", host: "::1" }, "/tmp/x")).toBe("root@[::1]:/tmp/x");
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe("remoteCommand + shellQuote", () => {
|
|
151
|
-
it("returns the command unchanged without sudo", () => {
|
|
152
|
-
expect(remoteCommand("whoami", false)).toBe("whoami");
|
|
153
|
-
});
|
|
154
|
-
it("wraps in sudo -S with a quoted inner command", () => {
|
|
155
|
-
const out = remoteCommand("apt update", true);
|
|
156
|
-
expect(out).toStartWith("sudo -S -p '' -- sh -c ");
|
|
157
|
-
expect(out).toContain("'apt update'");
|
|
158
|
-
});
|
|
159
|
-
it("escapes embedded single quotes safely", () => {
|
|
160
|
-
expect(shellQuote("it's")).toBe(`'it'\\''s'`);
|
|
161
|
-
// The wrapped form has no unescaped quote that would break out of the string.
|
|
162
|
-
const wrapped = remoteCommand("echo 'hi'", true);
|
|
163
|
-
expect(wrapped).toContain(`'echo '\\''hi'\\'''`);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
describe("detectSshFailure", () => {
|
|
168
|
-
it("is false on exit 0", () => {
|
|
169
|
-
expect(detectSshFailure(0, "permission denied")).toBe(false);
|
|
170
|
-
});
|
|
171
|
-
it("flags common ssh errors", () => {
|
|
172
|
-
expect(detectSshFailure(255, "Permission denied (publickey,password).")).toBe(true);
|
|
173
|
-
expect(detectSshFailure(255, "ssh: connect to host x: Connection refused")).toBe(true);
|
|
174
|
-
expect(detectSshFailure(255, "Could not resolve hostname x")).toBe(true);
|
|
175
|
-
});
|
|
176
|
-
it("does not flag a normal nonzero command exit", () => {
|
|
177
|
-
expect(detectSshFailure(1, "ls: no such file")).toBe(false);
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
describe("isUnreachable", () => {
|
|
182
|
-
it("flags connection/DNS failures (not auth)", () => {
|
|
183
|
-
expect(isUnreachable("ssh: connect to host x port 22: Connection timed out")).toBe(true);
|
|
184
|
-
expect(isUnreachable("ssh: connect to host x port 22: Connection refused")).toBe(true);
|
|
185
|
-
expect(isUnreachable("ssh: Could not resolve hostname x")).toBe(true);
|
|
186
|
-
expect(isUnreachable("ssh: connect to host x: No route to host")).toBe(true);
|
|
187
|
-
});
|
|
188
|
-
it("does NOT flag a password rejection (that's an auth failure)", () => {
|
|
189
|
-
expect(isUnreachable("Permission denied, please try again.")).toBe(false);
|
|
190
|
-
expect(isUnreachable("Permission denied (publickey,password).")).toBe(false);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe("detectSudoFailure", () => {
|
|
195
|
-
it("flags wrong sudo password", () => {
|
|
196
|
-
expect(detectSudoFailure("Sorry, try again.")).toBe(true);
|
|
197
|
-
expect(detectSudoFailure("sudo: 1 incorrect password attempt")).toBe(true);
|
|
198
|
-
expect(detectSudoFailure("sudo: a password is required")).toBe(true);
|
|
199
|
-
});
|
|
200
|
-
it("ignores unrelated stderr", () => {
|
|
201
|
-
expect(detectSudoFailure("warning: something")).toBe(false);
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
describe("filterSudoPrompt", () => {
|
|
206
|
-
it("strips bare password prompt lines", () => {
|
|
207
|
-
expect(filterSudoPrompt("[sudo] password for u:\nreal output")).toBe("real output");
|
|
208
|
-
expect(filterSudoPrompt("Password:\nx")).toBe("x");
|
|
209
|
-
});
|
|
210
|
-
it("keeps normal lines", () => {
|
|
211
|
-
expect(filterSudoPrompt("line one\nline two")).toBe("line one\nline two");
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe("hostApproved", () => {
|
|
216
|
-
it("is false for an unknown host", () => {
|
|
217
|
-
expect(hostApproved(new Map(), "u@h:22")).toBe(false);
|
|
218
|
-
});
|
|
219
|
-
it("is true within the TTL window", () => {
|
|
220
|
-
const m = new Map([["u@h:22", 1000]]);
|
|
221
|
-
expect(hostApproved(m, "u@h:22", 500)).toBe(true);
|
|
222
|
-
});
|
|
223
|
-
it("expires and self-prunes at/after the deadline", () => {
|
|
224
|
-
const m = new Map([["u@h:22", 1000]]);
|
|
225
|
-
expect(hostApproved(m, "u@h:22", 1000)).toBe(false);
|
|
226
|
-
expect(m.has("u@h:22")).toBe(false);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
describe("commandEscalatesPrivilege", () => {
|
|
231
|
-
it("flags leading and mid-chain escalation tokens", () => {
|
|
232
|
-
expect(commandEscalatesPrivilege("sudo apt update")).toBe(true);
|
|
233
|
-
expect(commandEscalatesPrivilege("apt update && sudo apt install x")).toBe(true);
|
|
234
|
-
expect(commandEscalatesPrivilege("foo; su -c 'x'")).toBe(true);
|
|
235
|
-
expect(commandEscalatesPrivilege("doas reboot")).toBe(true);
|
|
236
|
-
expect(commandEscalatesPrivilege("pkexec whoami")).toBe(true);
|
|
237
|
-
});
|
|
238
|
-
it("does not flag plain commands or substrings", () => {
|
|
239
|
-
expect(commandEscalatesPrivilege("ls -la")).toBe(false);
|
|
240
|
-
expect(commandEscalatesPrivilege("pseudo-tty")).toBe(false);
|
|
241
|
-
expect(commandEscalatesPrivilege("cat sudoku.txt")).toBe(false);
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
describe("truncate", () => {
|
|
246
|
-
it("passes short text through", () => {
|
|
247
|
-
expect(truncate("a\nb").truncated).toBe(false);
|
|
248
|
-
});
|
|
249
|
-
it("caps by line count", () => {
|
|
250
|
-
const many = Array.from({ length: 3000 }, (_, i) => `l${i}`).join("\n");
|
|
251
|
-
const out = truncate(many, 2000);
|
|
252
|
-
expect(out.truncated).toBe(true);
|
|
253
|
-
expect(out.text.split("\n").length).toBe(2000);
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
describe("ssh result renderer", () => {
|
|
258
|
-
const theme = {
|
|
259
|
-
fg: (key: string, text: string) => `[${key}]${text}[/]`,
|
|
260
|
-
bold: (text: string) => text,
|
|
261
|
-
};
|
|
262
|
-
const register = () => {
|
|
263
|
-
let renderer: ((...args: unknown[]) => { render(width: number): string[] }) | undefined;
|
|
264
|
-
registerSsh({
|
|
265
|
-
registerTool(tool: unknown) {
|
|
266
|
-
renderer = (tool as { renderResult: typeof renderer }).renderResult;
|
|
267
|
-
},
|
|
268
|
-
} as unknown as ExtensionAPI);
|
|
269
|
-
if (!renderer) throw new Error("renderResult not registered");
|
|
270
|
-
return renderer;
|
|
271
|
-
};
|
|
272
|
-
const render = (
|
|
273
|
-
renderer: (...args: unknown[]) => { render(width: number): string[] },
|
|
274
|
-
details: Record<string, unknown> | undefined,
|
|
275
|
-
isError = false,
|
|
276
|
-
isPartial = false,
|
|
277
|
-
state: Record<string, unknown> = {},
|
|
278
|
-
expanded = true,
|
|
279
|
-
) =>
|
|
280
|
-
renderer(
|
|
281
|
-
{ content: [{ type: "text", text: String(details?.outcome ?? "done") }], details },
|
|
282
|
-
{ isPartial },
|
|
283
|
-
theme,
|
|
284
|
-
{ expanded, isError, invalidate: () => {}, state },
|
|
285
|
-
)
|
|
286
|
-
.render(24)
|
|
287
|
-
.join("\n");
|
|
288
|
-
|
|
289
|
-
it("frames open terminal outcomes and generic results by status", () => {
|
|
290
|
-
const renderer = register();
|
|
291
|
-
expect(render(renderer, undefined)).toContain("[success]─");
|
|
292
|
-
expect(render(renderer, undefined, true)).toContain("[error]─");
|
|
293
|
-
for (const outcome of ["denied", "timed-out", "cancelled", "error"]) {
|
|
294
|
-
expect(
|
|
295
|
-
render(renderer, {
|
|
296
|
-
_type: "sshResult",
|
|
297
|
-
command: "id",
|
|
298
|
-
host: "host",
|
|
299
|
-
sudo: false,
|
|
300
|
-
outcome,
|
|
301
|
-
}),
|
|
302
|
-
).toContain("[error]─");
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("leaves partial, running, and collapsed rows unframed", () => {
|
|
307
|
-
const renderer = register();
|
|
308
|
-
const running = {
|
|
309
|
-
_type: "sshResult",
|
|
310
|
-
command: "id",
|
|
311
|
-
host: "host",
|
|
312
|
-
sudo: false,
|
|
313
|
-
outcome: "running",
|
|
314
|
-
};
|
|
315
|
-
expect(render(renderer, running, false, true)).not.toContain("[success]─");
|
|
316
|
-
expect(render(renderer, running)).not.toContain("[success]─");
|
|
317
|
-
const success = { ...running, outcome: "success", exitCode: 0, _render: "done" };
|
|
318
|
-
expect(render(renderer, success, false, false, { collapsed: true }, false)).not.toContain("─");
|
|
319
|
-
});
|
|
320
|
-
});
|