@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.3.1",
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.16.0",
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
- "ssh_run sends commands or transfers files/directories over SSH. Basic cmd, PowerShell, or " +
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 = code === null ? "dim" : code === 0 ? "success" : "error";
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 out = ruleFrame(
817
- show.map((line) => ` ${sectionRule(line, theme, sw) ?? line}`),
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
- });