@xynogen/pix-ssh 0.3.1 → 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 +3 -2
- package/src/index.ts +14 -25
- package/src/lib.test.ts +0 -253
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
|
],
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"typebox": "^1.1.38",
|
|
40
|
-
"@xynogen/pix-pretty": "^1.
|
|
41
|
+
"@xynogen/pix-pretty": "^1.19.0",
|
|
41
42
|
"@xynogen/pix-runtime": "^0.8.0"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-
|
|
|
35
35
|
import {
|
|
36
36
|
dotJoin,
|
|
37
37
|
fillToolBackground,
|
|
38
|
+
frameToolResult,
|
|
38
39
|
getTextContent,
|
|
39
40
|
hideCollapsedToolCall,
|
|
40
41
|
normalizeLineEndings,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
ruleFrame,
|
|
44
45
|
sectionRule,
|
|
45
46
|
termW,
|
|
47
|
+
unframeToolResult,
|
|
46
48
|
} from "@xynogen/pix-pretty/utils";
|
|
47
49
|
import { SPINNER } from "@xynogen/pix-pretty/widget-format";
|
|
48
50
|
import { getUnattendedMode, withAgentBlock } from "@xynogen/pix-runtime";
|
|
@@ -310,14 +312,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
310
312
|
"`destination`, and optional `recursive`. Transfers may overwrite the destination. Always provide a clear `reason`.",
|
|
311
313
|
promptSnippet: "Run a remote command or transfer files over SSH",
|
|
312
314
|
promptGuidelines: [
|
|
313
|
-
|
|
314
|
-
"pwsh commands may work, but treat Windows shells as best-effort. Back away and tell the user " +
|
|
315
|
-
"when correctness depends on explicit shell selection, complex quoting, PowerShell error/stream/encoding " +
|
|
316
|
-
"semantics, interactive prompts, or Windows administrator/UAC elevation. `sudo` covers POSIX sudo " +
|
|
317
|
-
'only. Provide `host` as `[user@]host[:port]`. For file transfer, use `action: "file"`, ' +
|
|
318
|
-
'`direction: "upload" | "download"`, `source`, `destination`, and optional `recursive`. ' +
|
|
319
|
-
"Transfers may overwrite the destination and are warning-level: AFK/YOLO auto-approve when " +
|
|
320
|
-
"no login password is missing. Always explain the intent in `reason`.",
|
|
315
|
+
'ssh_run: `host` as `[user@]host[:port]`; `sudo` covers POSIX sudo only. For transfer use `action: "file"` with `direction: "upload"|"download"`, `source`, `destination`, optional `recursive` (may overwrite). Always set `reason`.',
|
|
321
316
|
],
|
|
322
317
|
|
|
323
318
|
renderShell: "self",
|
|
@@ -737,8 +732,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
737
732
|
renderCtx: RenderContextLike,
|
|
738
733
|
) => {
|
|
739
734
|
resolveBaseBackground(theme);
|
|
740
|
-
const text = renderCtx.lastComponent ?? new Text("", 0, 0);
|
|
735
|
+
const text = unframeToolResult(renderCtx.lastComponent ?? new Text("", 0, 0));
|
|
741
736
|
const details = result.details as SshResultDetails | undefined;
|
|
737
|
+
const isPartial = (_opt as { isPartial?: boolean } | undefined)?.isPartial === true;
|
|
738
|
+
const completed = (isError: boolean) => frameToolResult(text, theme, isError);
|
|
742
739
|
|
|
743
740
|
if (details?._type !== "sshResult") {
|
|
744
741
|
if (renderCtx.isError) {
|
|
@@ -748,10 +745,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
748
745
|
fillToolBackground(` ${theme.fg("muted", getTextContent(result) || "done")}`),
|
|
749
746
|
);
|
|
750
747
|
}
|
|
751
|
-
return text;
|
|
748
|
+
return isPartial ? text : completed(renderCtx.isError);
|
|
752
749
|
}
|
|
753
750
|
|
|
754
751
|
if (
|
|
752
|
+
!isPartial &&
|
|
755
753
|
isTerminal(details) &&
|
|
756
754
|
tickCollapse(
|
|
757
755
|
"ssh",
|
|
@@ -760,12 +758,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
760
758
|
renderCtx.expanded,
|
|
761
759
|
)
|
|
762
760
|
) {
|
|
763
|
-
const status =
|
|
764
|
-
details.outcome === "success"
|
|
765
|
-
? "success"
|
|
766
|
-
: details.outcome === "error"
|
|
767
|
-
? "error"
|
|
768
|
-
: "warning";
|
|
761
|
+
const status = details.outcome === "success" ? "success" : "error";
|
|
769
762
|
text.setText(
|
|
770
763
|
renderCollapsedToolRow(
|
|
771
764
|
theme,
|
|
@@ -792,7 +785,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
792
785
|
? renderToolError(diagnostic, theme)
|
|
793
786
|
: fillToolBackground(` ${theme.fg("warning", diagnostic)}`),
|
|
794
787
|
);
|
|
795
|
-
return text;
|
|
788
|
+
return isPartial ? text : completed(true);
|
|
796
789
|
}
|
|
797
790
|
|
|
798
791
|
const code = typeof details.exitCode === "number" ? details.exitCode : null;
|
|
@@ -803,22 +796,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
803
796
|
|
|
804
797
|
if (!rendered) {
|
|
805
798
|
text.setText(fillToolBackground(` ${summary}`));
|
|
806
|
-
return text;
|
|
799
|
+
return isPartial ? text : completed(details.outcome !== "success");
|
|
807
800
|
}
|
|
808
801
|
|
|
809
802
|
const maxShow = renderCtx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
810
803
|
const show = lines.slice(0, maxShow);
|
|
811
804
|
const footer =
|
|
812
805
|
lineCount > maxShow ? [`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`] : [];
|
|
813
|
-
const statusKey =
|
|
806
|
+
const statusKey = details.outcome === "success" ? "success" : "error";
|
|
814
807
|
const paint = (s: string) => theme.fg(statusKey, s);
|
|
815
808
|
const sw = Math.max(8, termW() - 4); // section-rule width inside the 2-space indent
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
footer,
|
|
819
|
-
termW(),
|
|
820
|
-
paint,
|
|
821
|
-
);
|
|
809
|
+
const body = show.map((line) => ` ${sectionRule(line, theme, sw) ?? line}`);
|
|
810
|
+
const out = isPartial ? [...body, ...footer] : ruleFrame(body, footer, termW(), paint);
|
|
822
811
|
text.setText(fillToolBackground(out.join("\n")));
|
|
823
812
|
return text;
|
|
824
813
|
}) as never,
|
package/src/lib.test.ts
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
baseScpArgs,
|
|
4
|
-
baseSshArgs,
|
|
5
|
-
commandEscalatesPrivilege,
|
|
6
|
-
controlPathFor,
|
|
7
|
-
detectSshFailure,
|
|
8
|
-
detectSudoFailure,
|
|
9
|
-
filterSudoPrompt,
|
|
10
|
-
hostApproved,
|
|
11
|
-
hostTarget,
|
|
12
|
-
isUnreachable,
|
|
13
|
-
parseHost,
|
|
14
|
-
parseSshConfig,
|
|
15
|
-
remoteCommand,
|
|
16
|
-
remoteTransferPath,
|
|
17
|
-
shellQuote,
|
|
18
|
-
transferApprovalDecision,
|
|
19
|
-
transferArgs,
|
|
20
|
-
truncate,
|
|
21
|
-
} from "./lib.ts";
|
|
22
|
-
|
|
23
|
-
describe("parseHost", () => {
|
|
24
|
-
it("parses bare host", () => {
|
|
25
|
-
expect(parseHost("example.com")).toEqual({ host: "example.com" });
|
|
26
|
-
});
|
|
27
|
-
it("parses user@host", () => {
|
|
28
|
-
expect(parseHost("deploy@10.0.0.5")).toEqual({ user: "deploy", host: "10.0.0.5" });
|
|
29
|
-
});
|
|
30
|
-
it("parses user@host:port", () => {
|
|
31
|
-
expect(parseHost("deploy@10.0.0.5:2222")).toEqual({
|
|
32
|
-
user: "deploy",
|
|
33
|
-
host: "10.0.0.5",
|
|
34
|
-
port: 2222,
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
it("parses bracketed IPv6 with port", () => {
|
|
38
|
-
expect(parseHost("root@[::1]:22")).toEqual({ user: "root", host: "::1", port: 22 });
|
|
39
|
-
});
|
|
40
|
-
it("leaves bare IPv6 (multiple colons) as host", () => {
|
|
41
|
-
expect(parseHost("fe80::1")).toEqual({ host: "fe80::1" });
|
|
42
|
-
});
|
|
43
|
-
it("rejects empty host", () => {
|
|
44
|
-
expect(() => parseHost(" ")).toThrow();
|
|
45
|
-
expect(() => parseHost("user@")).toThrow();
|
|
46
|
-
});
|
|
47
|
-
it("rejects invalid port", () => {
|
|
48
|
-
expect(() => parseHost("h:0")).toThrow();
|
|
49
|
-
expect(() => parseHost("h:70000")).toThrow();
|
|
50
|
-
expect(() => parseHost("h:abc")).toThrow();
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("parseSshConfig", () => {
|
|
55
|
-
it("reads effective user, hostname, and port from ssh -G output", () => {
|
|
56
|
-
expect(parseSshConfig("host orin\nuser jetson\nhostname 10.10.21.251\nport 2222\n")).toEqual({
|
|
57
|
-
user: "jetson",
|
|
58
|
-
host: "10.10.21.251",
|
|
59
|
-
port: 2222,
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("rejects incomplete ssh -G output", () => {
|
|
64
|
-
expect(parseSshConfig("hostname 10.10.21.251\n")).toBeUndefined();
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe("hostTarget", () => {
|
|
69
|
-
it("joins user and host", () => {
|
|
70
|
-
expect(hostTarget({ user: "a", host: "b" })).toBe("a@b");
|
|
71
|
-
expect(hostTarget({ host: "b" })).toBe("b");
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe("controlPathFor", () => {
|
|
76
|
-
it("is stable per host+user+port and differs across hosts", () => {
|
|
77
|
-
const a = controlPathFor({ user: "u", host: "h", port: 22 });
|
|
78
|
-
expect(a).toBe(controlPathFor({ user: "u", host: "h", port: 22 }));
|
|
79
|
-
expect(a).not.toBe(controlPathFor({ user: "u", host: "h", port: 23 }));
|
|
80
|
-
expect(a).not.toBe(controlPathFor({ user: "x", host: "h", port: 22 }));
|
|
81
|
-
expect(a).toContain("pix-ssh-");
|
|
82
|
-
expect(a.endsWith(".sock")).toBe(true);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe("baseSshArgs", () => {
|
|
87
|
-
it("enables multiplexing and includes the control path", () => {
|
|
88
|
-
const args = baseSshArgs({ host: "h" }, "/tmp/x.sock");
|
|
89
|
-
expect(args).toContain("ControlMaster=auto");
|
|
90
|
-
expect(args).toContain("ControlPath=/tmp/x.sock");
|
|
91
|
-
expect(args.some((a) => a.startsWith("ControlPersist="))).toBe(true);
|
|
92
|
-
});
|
|
93
|
-
it("adds -p only when a port is set", () => {
|
|
94
|
-
expect(baseSshArgs({ host: "h" }, "s")).not.toContain("-p");
|
|
95
|
-
const withPort = baseSshArgs({ host: "h", port: 2222 }, "s");
|
|
96
|
-
expect(withPort).toContain("-p");
|
|
97
|
-
expect(withPort).toContain("2222");
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe("transfer unattended approval", () => {
|
|
102
|
-
it("treats transfer as warning-level in AFK and YOLO", () => {
|
|
103
|
-
expect(transferApprovalDecision("off", false)).toBe("ask");
|
|
104
|
-
expect(transferApprovalDecision("afk", false)).toBe("allow");
|
|
105
|
-
expect(transferApprovalDecision("yolo", false)).toBe("allow");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("denies unattended transfer when a login password is missing", () => {
|
|
109
|
-
expect(transferApprovalDecision("afk", true)).toBe("deny");
|
|
110
|
-
expect(transferApprovalDecision("yolo", true)).toBe("deny");
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("SCP transfer arguments", () => {
|
|
115
|
-
it("builds upload and download endpoints", () => {
|
|
116
|
-
const spec = { user: "deploy", host: "example.com", port: 2222 };
|
|
117
|
-
expect(remoteTransferPath(spec, "/srv/app file")).toBe("deploy@example.com:/srv/app file");
|
|
118
|
-
expect(transferArgs(spec, "upload", "./build", "/srv/app")).toEqual([
|
|
119
|
-
"./build",
|
|
120
|
-
"deploy@example.com:/srv/app",
|
|
121
|
-
]);
|
|
122
|
-
expect(transferArgs(spec, "download", "/var/log/app.log", "./app.log")).toEqual([
|
|
123
|
-
"deploy@example.com:/var/log/app.log",
|
|
124
|
-
"./app.log",
|
|
125
|
-
]);
|
|
126
|
-
expect(baseScpArgs(spec, "/tmp/control.sock", true)).toEqual([
|
|
127
|
-
"-o",
|
|
128
|
-
"ControlMaster=auto",
|
|
129
|
-
"-o",
|
|
130
|
-
"ControlPath=/tmp/control.sock",
|
|
131
|
-
"-o",
|
|
132
|
-
"ControlPersist=120",
|
|
133
|
-
"-o",
|
|
134
|
-
"ConnectTimeout=10",
|
|
135
|
-
"-o",
|
|
136
|
-
"StrictHostKeyChecking=accept-new",
|
|
137
|
-
"-P",
|
|
138
|
-
"2222",
|
|
139
|
-
"-r",
|
|
140
|
-
]);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("brackets IPv6 hosts for SCP remote-path syntax", () => {
|
|
144
|
-
expect(remoteTransferPath({ user: "root", host: "::1" }, "/tmp/x")).toBe("root@[::1]:/tmp/x");
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
describe("remoteCommand + shellQuote", () => {
|
|
149
|
-
it("returns the command unchanged without sudo", () => {
|
|
150
|
-
expect(remoteCommand("whoami", false)).toBe("whoami");
|
|
151
|
-
});
|
|
152
|
-
it("wraps in sudo -S with a quoted inner command", () => {
|
|
153
|
-
const out = remoteCommand("apt update", true);
|
|
154
|
-
expect(out).toStartWith("sudo -S -p '' -- sh -c ");
|
|
155
|
-
expect(out).toContain("'apt update'");
|
|
156
|
-
});
|
|
157
|
-
it("escapes embedded single quotes safely", () => {
|
|
158
|
-
expect(shellQuote("it's")).toBe(`'it'\\''s'`);
|
|
159
|
-
// The wrapped form has no unescaped quote that would break out of the string.
|
|
160
|
-
const wrapped = remoteCommand("echo 'hi'", true);
|
|
161
|
-
expect(wrapped).toContain(`'echo '\\''hi'\\'''`);
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
describe("detectSshFailure", () => {
|
|
166
|
-
it("is false on exit 0", () => {
|
|
167
|
-
expect(detectSshFailure(0, "permission denied")).toBe(false);
|
|
168
|
-
});
|
|
169
|
-
it("flags common ssh errors", () => {
|
|
170
|
-
expect(detectSshFailure(255, "Permission denied (publickey,password).")).toBe(true);
|
|
171
|
-
expect(detectSshFailure(255, "ssh: connect to host x: Connection refused")).toBe(true);
|
|
172
|
-
expect(detectSshFailure(255, "Could not resolve hostname x")).toBe(true);
|
|
173
|
-
});
|
|
174
|
-
it("does not flag a normal nonzero command exit", () => {
|
|
175
|
-
expect(detectSshFailure(1, "ls: no such file")).toBe(false);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe("isUnreachable", () => {
|
|
180
|
-
it("flags connection/DNS failures (not auth)", () => {
|
|
181
|
-
expect(isUnreachable("ssh: connect to host x port 22: Connection timed out")).toBe(true);
|
|
182
|
-
expect(isUnreachable("ssh: connect to host x port 22: Connection refused")).toBe(true);
|
|
183
|
-
expect(isUnreachable("ssh: Could not resolve hostname x")).toBe(true);
|
|
184
|
-
expect(isUnreachable("ssh: connect to host x: No route to host")).toBe(true);
|
|
185
|
-
});
|
|
186
|
-
it("does NOT flag a password rejection (that's an auth failure)", () => {
|
|
187
|
-
expect(isUnreachable("Permission denied, please try again.")).toBe(false);
|
|
188
|
-
expect(isUnreachable("Permission denied (publickey,password).")).toBe(false);
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
describe("detectSudoFailure", () => {
|
|
193
|
-
it("flags wrong sudo password", () => {
|
|
194
|
-
expect(detectSudoFailure("Sorry, try again.")).toBe(true);
|
|
195
|
-
expect(detectSudoFailure("sudo: 1 incorrect password attempt")).toBe(true);
|
|
196
|
-
expect(detectSudoFailure("sudo: a password is required")).toBe(true);
|
|
197
|
-
});
|
|
198
|
-
it("ignores unrelated stderr", () => {
|
|
199
|
-
expect(detectSudoFailure("warning: something")).toBe(false);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe("filterSudoPrompt", () => {
|
|
204
|
-
it("strips bare password prompt lines", () => {
|
|
205
|
-
expect(filterSudoPrompt("[sudo] password for u:\nreal output")).toBe("real output");
|
|
206
|
-
expect(filterSudoPrompt("Password:\nx")).toBe("x");
|
|
207
|
-
});
|
|
208
|
-
it("keeps normal lines", () => {
|
|
209
|
-
expect(filterSudoPrompt("line one\nline two")).toBe("line one\nline two");
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
describe("hostApproved", () => {
|
|
214
|
-
it("is false for an unknown host", () => {
|
|
215
|
-
expect(hostApproved(new Map(), "u@h:22")).toBe(false);
|
|
216
|
-
});
|
|
217
|
-
it("is true within the TTL window", () => {
|
|
218
|
-
const m = new Map([["u@h:22", 1000]]);
|
|
219
|
-
expect(hostApproved(m, "u@h:22", 500)).toBe(true);
|
|
220
|
-
});
|
|
221
|
-
it("expires and self-prunes at/after the deadline", () => {
|
|
222
|
-
const m = new Map([["u@h:22", 1000]]);
|
|
223
|
-
expect(hostApproved(m, "u@h:22", 1000)).toBe(false);
|
|
224
|
-
expect(m.has("u@h:22")).toBe(false);
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
describe("commandEscalatesPrivilege", () => {
|
|
229
|
-
it("flags leading and mid-chain escalation tokens", () => {
|
|
230
|
-
expect(commandEscalatesPrivilege("sudo apt update")).toBe(true);
|
|
231
|
-
expect(commandEscalatesPrivilege("apt update && sudo apt install x")).toBe(true);
|
|
232
|
-
expect(commandEscalatesPrivilege("foo; su -c 'x'")).toBe(true);
|
|
233
|
-
expect(commandEscalatesPrivilege("doas reboot")).toBe(true);
|
|
234
|
-
expect(commandEscalatesPrivilege("pkexec whoami")).toBe(true);
|
|
235
|
-
});
|
|
236
|
-
it("does not flag plain commands or substrings", () => {
|
|
237
|
-
expect(commandEscalatesPrivilege("ls -la")).toBe(false);
|
|
238
|
-
expect(commandEscalatesPrivilege("pseudo-tty")).toBe(false);
|
|
239
|
-
expect(commandEscalatesPrivilege("cat sudoku.txt")).toBe(false);
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
describe("truncate", () => {
|
|
244
|
-
it("passes short text through", () => {
|
|
245
|
-
expect(truncate("a\nb").truncated).toBe(false);
|
|
246
|
-
});
|
|
247
|
-
it("caps by line count", () => {
|
|
248
|
-
const many = Array.from({ length: 3000 }, (_, i) => `l${i}`).join("\n");
|
|
249
|
-
const out = truncate(many, 2000);
|
|
250
|
-
expect(out.truncated).toBe(true);
|
|
251
|
-
expect(out.text.split("\n").length).toBe(2000);
|
|
252
|
-
});
|
|
253
|
-
});
|