@xynogen/pix-ssh 0.2.1 → 0.2.3

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 CHANGED
@@ -1,12 +1,15 @@
1
1
  # pix-ssh
2
2
 
3
- Pi tool — `ssh_run`: run a shell command on a remote host over SSH, optionally as remote root.
3
+ Pi tool — `ssh_run`: run a command through a remote host's configured SSH shell, optionally through POSIX `sudo`.
4
+
5
+ > [!IMPORTANT]
6
+ > Basic Windows `cmd`, Windows PowerShell, and `pwsh` commands may work through the configured SSH shell, but support is best-effort. Shell selection, complex quoting, PowerShell error/stream/encoding semantics, interactive prompts, and Windows administrator/UAC elevation are not covered. The agent should back away when correctness depends on those limits. `sudo: true` supports POSIX `sudo` only.
4
7
 
5
8
  ## What it does
6
9
 
7
- Registers the `ssh_run` tool, which executes a command on a remote machine behind a permission dialog (the shared overlay from `@xynogen/pix-pretty`, the same one pix-sudo uses). Every command requires explicit per-call Allow/Deny approval in the UI, with a 60-second auto-deny timeout — that approval step is never skipped. Output is truncated to 50 KB / 2000 lines. In non-interactive (RPC/JSON) mode the tool is blocked immediately.
10
+ Registers the `ssh_run` tool, which executes a command through the configured SSH shell on a remote machine behind a permission dialog (the shared overlay from `@xynogen/pix-pretty`, the same one pix-sudo uses). Initial and privileged calls require Allow/Deny approval in the UI, with a 60-second auto-deny timeout. Non-privileged calls may be auto-approved during the 15-minute per-host approval window or in YOLO mode when no password is missing; each host-window auto-approval emits a notification. Output is truncated to 50 KB / 2000 lines. In non-interactive (RPC/JSON) mode the tool is blocked immediately.
8
11
 
9
- **Parameters:** `host` as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`), `command`, optional `sudo` (run as root on the remote), optional `reason`.
12
+ **Parameters:** `host` as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`), `command`, optional `sudo` (run through POSIX `sudo` as root), optional `reason`.
10
13
 
11
14
  ### Authentication
12
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  MAX_OUTPUT_LINES,
60
60
  parseHost,
61
61
  probeKeyAuth,
62
+ resolveSshHost,
62
63
  runSsh,
63
64
  truncate,
64
65
  } from "./lib.ts";
@@ -216,17 +217,22 @@ export default function (pi: ExtensionAPI): void {
216
217
  name: "ssh_run",
217
218
  label: "Run over SSH",
218
219
  description:
219
- "Run a shell command on a remote host over SSH, optionally as root (remote sudo). " +
220
- "Handles the connection and any password entry through a confirmation dialog — " +
221
- "the command is NEVER executed without explicit user approval. " +
220
+ "Run a command through the remote host's configured SSH shell, optionally through POSIX sudo. " +
221
+ "Basic cmd/PowerShell/pwsh commands may work, but Windows shells are best-effort: shell selection, " +
222
+ "quoting, PowerShell error/stream/encoding semantics, interactive prompts, and Windows " +
223
+ "administrator/UAC elevation are not supported. Back away and tell the user when correctness " +
224
+ "depends on one of those limits. Handles connection and password entry through a permission dialog. " +
225
+ "A configured approval window or YOLO mode may auto-approve non-privileged commands when no password is missing. " +
222
226
  "SSH auth tries key/agent first, then prompts for a login password if needed. " +
223
227
  "Set `sudo: true` to run the command as root on the remote machine (prompts for the " +
224
228
  "remote sudo password). Always provide a clear `reason`.",
225
- promptSnippet: "Run a command on a remote host over SSH (optionally as remote root)",
229
+ promptSnippet: "Run a remote SSH command (Windows shells best-effort; POSIX sudo only)",
226
230
  promptGuidelines: [
227
- "Use ssh_run to execute commands on a remote machine over SSH. Provide `host` as " +
228
- "`[user@]host[:port]`. Set `sudo: true` only when the remote command needs root. " +
229
- "Always set `reason` to a short plain-English sentence explaining the intent.",
231
+ "ssh_run sends commands to the remote host's configured SSH shell. Basic cmd, PowerShell, or " +
232
+ "pwsh commands may work, but treat Windows shells as best-effort. Back away and tell the user " +
233
+ "when correctness depends on explicit shell selection, complex quoting, PowerShell error/stream/encoding " +
234
+ "semantics, interactive prompts, or Windows administrator/UAC elevation. `sudo` covers POSIX sudo " +
235
+ "only. Provide `host` as `[user@]host[:port]` and always explain the intent in `reason`.",
230
236
  ],
231
237
 
232
238
  renderShell: "self",
@@ -236,7 +242,7 @@ export default function (pi: ExtensionAPI): void {
236
242
  description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
237
243
  }),
238
244
  command: Type.String({
239
- description: "Shell command to run on the remote host (passed to remote `sh -c`).",
245
+ description: "Command sent to the remote host's configured SSH shell.",
240
246
  }),
241
247
  sudo: Type.Optional(
242
248
  Type.Boolean({
@@ -268,9 +274,14 @@ export default function (pi: ExtensionAPI): void {
268
274
  isError: true,
269
275
  };
270
276
  }
271
- const host = hostTarget(spec);
272
- const controlPath = controlPathFor(spec);
273
- const key = cacheKey(spec);
277
+ // ponytail: let OpenSSH own config parsing; `ssh -G` handles aliases,
278
+ // Include, and Match rules without duplicating its config grammar.
279
+ const effectiveSpec = await resolveSshHost(spec, sig);
280
+ const host = hostTarget(effectiveSpec);
281
+ // Keep original target for execution so alias-specific IdentityFile,
282
+ // ProxyJump, and other SSH config options still apply.
283
+ const controlPath = controlPathFor(effectiveSpec);
284
+ const key = cacheKey(effectiveSpec);
274
285
  const creds = credCache.get(key) ?? {};
275
286
 
276
287
  const g = globalThis as { __pixAfk?: boolean; __pixYolo?: boolean };
package/src/lib.test.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  hostTarget,
11
11
  isUnreachable,
12
12
  parseHost,
13
+ parseSshConfig,
13
14
  remoteCommand,
14
15
  shellQuote,
15
16
  truncate,
@@ -46,6 +47,20 @@ describe("parseHost", () => {
46
47
  });
47
48
  });
48
49
 
50
+ describe("parseSshConfig", () => {
51
+ it("reads effective user, hostname, and port from ssh -G output", () => {
52
+ expect(parseSshConfig("host orin\nuser jetson\nhostname 10.10.21.251\nport 2222\n")).toEqual({
53
+ user: "jetson",
54
+ host: "10.10.21.251",
55
+ port: 2222,
56
+ });
57
+ });
58
+
59
+ it("rejects incomplete ssh -G output", () => {
60
+ expect(parseSshConfig("hostname 10.10.21.251\n")).toBeUndefined();
61
+ });
62
+ });
63
+
49
64
  describe("hostTarget", () => {
50
65
  it("joins user and host", () => {
51
66
  expect(hostTarget({ user: "a", host: "b" })).toBe("a@b");
package/src/lib.ts CHANGED
@@ -108,6 +108,39 @@ function parsePort(value: string): number {
108
108
  return n;
109
109
  }
110
110
 
111
+ /** Parse effective destination fields emitted by OpenSSH's `ssh -G`. */
112
+ export function parseSshConfig(output: string): HostSpec | undefined {
113
+ const values = new Map(
114
+ output
115
+ .split("\n")
116
+ .map((line) => line.trim().split(/\s+/, 2))
117
+ .filter((parts): parts is [string, string] => parts.length === 2),
118
+ );
119
+ const user = values.get("user");
120
+ const host = values.get("hostname");
121
+ const port = values.get("port");
122
+ if (!user || !host || !port) return undefined;
123
+ return { user, host, port: parsePort(port) };
124
+ }
125
+
126
+ /** Resolve aliases through OpenSSH config, including Include and Match rules. */
127
+ export function resolveSshHost(spec: HostSpec, signal?: AbortSignal): Promise<HostSpec> {
128
+ const args = ["-G"];
129
+ if (spec.port !== undefined) args.push("-p", String(spec.port));
130
+ args.push(hostTarget(spec));
131
+
132
+ return new Promise((resolve) => {
133
+ let stdout = "";
134
+ const proc = spawn("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
135
+ proc.stdout.on("data", (chunk: Buffer) => {
136
+ stdout += chunk.toString();
137
+ });
138
+ proc.on("error", () => resolve(spec));
139
+ proc.on("close", (code) => resolve(code === 0 ? (parseSshConfig(stdout) ?? spec) : spec));
140
+ signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
141
+ });
142
+ }
143
+
111
144
  /** Canonical `[user@]host` target string for ssh argv. */
112
145
  export function hostTarget(spec: HostSpec): string {
113
146
  return spec.user ? `${spec.user}@${spec.host}` : spec.host;