@xynogen/pix-ssh 0.3.4 → 0.4.0

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
@@ -14,7 +14,14 @@ Registers the `ssh_run` tool — run a command through the configured SSH shell
14
14
 
15
15
  Output is truncated to 50 KB / 2000 lines. Non-interactive (RPC/JSON) mode blocks the tool immediately.
16
16
 
17
- **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`.
17
+ **Parameters:** `action` (`"command"` default · `"file"` · `"info"`), `host` as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`), `command`, optional `sudo` (run through POSIX `sudo` as root), optional `reason`.
18
+
19
+ ### `action: "info"` — SSH config, no connection
20
+
21
+ Lets the agent discover hosts without reading `~/.ssh/config` itself. Read-only: no connection, no approval dialog, no password.
22
+
23
+ - **No `host`** → lists configured host aliases from `~/.ssh/config` and every file it pulls in via `Include` (glob + relative paths resolved OpenSSH-style, cycle-guarded). Wildcard-only patterns (`Host *`) are skipped. Each row shows `alias → user@hostname:port` plus `via <ProxyJump>` when set.
24
+ - **With `host`** → runs `ssh -G <host>` and reports the effective `HostName`/`User`/`Port`/`ProxyJump`/`IdentityFile` for that target (alias resolution, `Match`, and `Include` all handled by OpenSSH). No packets are sent to the remote.
18
25
 
19
26
  ### Authentication
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
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
@@ -56,6 +56,8 @@ import {
56
56
  controlPathFor,
57
57
  detectSshFailure,
58
58
  detectSudoFailure,
59
+ type HostAlias,
60
+ type HostInfo,
59
61
  type HostSpec,
60
62
  hostApproved,
61
63
  hostTarget,
@@ -63,6 +65,8 @@ import {
63
65
  MAX_OUTPUT_LINES,
64
66
  parseHost,
65
67
  probeKeyAuth,
68
+ readSshConfigAliases,
69
+ resolveHostInfo,
66
70
  resolveSshHost,
67
71
  runSsh,
68
72
  runTransfer,
@@ -122,23 +126,19 @@ export interface SshResultDetails {
122
126
  _render?: string;
123
127
  }
124
128
 
125
- type SshParams =
126
- | {
127
- action?: "command";
128
- host: string;
129
- command: string;
130
- sudo?: boolean;
131
- reason?: string;
132
- }
133
- | {
134
- action: "file";
135
- host: string;
136
- direction: TransferDirection;
137
- source: string;
138
- destination: string;
139
- recursive?: boolean;
140
- reason?: string;
141
- };
129
+ // Flat shape mirroring the single Type.Object schema. Conditional fields are
130
+ // optional here and validated at runtime by normalizeOperation.
131
+ type SshParams = {
132
+ action?: "command" | "file" | "info";
133
+ host?: string;
134
+ command?: string;
135
+ sudo?: boolean;
136
+ direction?: TransferDirection;
137
+ source?: string;
138
+ destination?: string;
139
+ recursive?: boolean;
140
+ reason?: string;
141
+ };
142
142
 
143
143
  interface SshOperation {
144
144
  action: "command" | "file";
@@ -153,11 +153,11 @@ interface SshOperation {
153
153
 
154
154
  function normalizeOperation(params: SshParams): SshOperation {
155
155
  if (params.action === "file") {
156
- const source = params.source.trim();
157
- const destination = params.destination.trim();
156
+ const source = (params.source ?? "").trim();
157
+ const destination = (params.destination ?? "").trim();
158
158
  return {
159
159
  action: "file",
160
- command: `${params.direction} ${source || "(empty source)"} → ${destination || "(empty destination)"}`,
160
+ command: `${params.direction ?? ""} ${source || "(empty source)"} → ${destination || "(empty destination)"}`,
161
161
  sudo: false,
162
162
  reason: params.reason,
163
163
  direction: params.direction,
@@ -168,7 +168,7 @@ function normalizeOperation(params: SshParams): SshOperation {
168
168
  }
169
169
  return {
170
170
  action: "command",
171
- command: params.command,
171
+ command: params.command ?? "",
172
172
  sudo: params.sudo === true,
173
173
  reason: params.reason,
174
174
  source: "",
@@ -293,6 +293,67 @@ function cancelResult(
293
293
  };
294
294
  }
295
295
 
296
+ function formatHostInfo(host: string, info: HostInfo): string {
297
+ const rows = [
298
+ ["HostName", info.hostname],
299
+ ["User", info.user],
300
+ ["Port", info.port],
301
+ ["ProxyJump", info.proxyJump && info.proxyJump !== "none" ? info.proxyJump : undefined],
302
+ ["IdentityFile", info.identityFile],
303
+ ].filter((r): r is [string, string] => Boolean(r[1]));
304
+ if (rows.length === 0) return `No SSH config found for ${host}.`;
305
+ return [`Effective SSH config for ${host}:`, ...rows.map(([k, v]) => ` ${k} ${v}`)].join("\n");
306
+ }
307
+
308
+ function formatAliasList(aliases: HostAlias[]): string {
309
+ if (aliases.length === 0) {
310
+ return "No SSH host aliases found in ~/.ssh/config.";
311
+ }
312
+ // De-dupe by alias, first block wins (OpenSSH semantics).
313
+ const seen = new Set<string>();
314
+ const lines: string[] = [];
315
+ for (const a of aliases) {
316
+ if (seen.has(a.alias)) continue;
317
+ seen.add(a.alias);
318
+ let target = "";
319
+ if (a.hostname) {
320
+ const userPart = a.user ? `${a.user}@` : "";
321
+ const portPart = a.port ? `:${a.port}` : "";
322
+ target = `${userPart}${a.hostname}${portPart}`;
323
+ }
324
+ const via = a.proxyJump && a.proxyJump !== "none" ? ` via ${a.proxyJump}` : "";
325
+ lines.push(` ${a.alias}${target ? ` → ${target}` : ""}${via}`);
326
+ }
327
+ return [`SSH host aliases (${lines.length}):`, ...lines].join("\n");
328
+ }
329
+
330
+ /** Build the info-action result: per-host effective config when `host` is
331
+ * given, otherwise the alias inventory from ~/.ssh/config. Read-only.
332
+ * Details carry `_type: "sshInfo"` so renderResult falls to its generic
333
+ * plain-text branch (no collapse, no exit-code framing). */
334
+ async function infoResult(host: string | undefined, sig?: AbortSignal) {
335
+ const details = { _type: "sshInfo" as const };
336
+ if (host?.trim()) {
337
+ let spec: HostSpec;
338
+ try {
339
+ spec = parseHost(host);
340
+ } catch (err) {
341
+ const msg = err instanceof Error ? err.message : String(err);
342
+ return {
343
+ content: [{ type: "text" as const, text: `ssh_run failed: ${msg}` }],
344
+ details,
345
+ isError: true,
346
+ };
347
+ }
348
+ const text = formatHostInfo(hostTarget(spec), await resolveHostInfo(spec, sig));
349
+ return { content: [{ type: "text" as const, text }], details };
350
+ }
351
+ return {
352
+ content: [{ type: "text" as const, text: formatAliasList(readSshConfigAliases()) }],
353
+ details,
354
+ };
355
+ }
356
+
296
357
  // ── Extension entry point ─────────────────────────────────────────────────────
297
358
 
298
359
  export default function (pi: ExtensionAPI): void {
@@ -300,67 +361,95 @@ export default function (pi: ExtensionAPI): void {
300
361
  name: "ssh_run",
301
362
  label: "Run over SSH",
302
363
  description:
303
- "Run a command or transfer files/directories through SSH. Commands may optionally use POSIX sudo. " +
304
- "Basic cmd/PowerShell/pwsh commands may work, but Windows shells are best-effort: shell selection, " +
305
- "quoting, PowerShell error/stream/encoding semantics, interactive prompts, and Windows " +
306
- "administrator/UAC elevation are not supported. Back away and tell the user when correctness " +
307
- "depends on one of those limits. Handles connection and password entry through a permission dialog. " +
308
- "A configured approval window or YOLO mode may auto-approve non-privileged commands when no password is missing. " +
309
- "SSH auth tries key/agent first, then prompts for a login password if needed. " +
310
- "Set `sudo: true` to run the command as root on the remote machine (prompts for the " +
311
- 'remote sudo password). For transfer, set `action: "file"`, `direction`, `source`, ' +
312
- "`destination`, and optional `recursive`. Transfers may overwrite the destination. Always provide a clear `reason`.",
313
- promptSnippet: "Run a remote command or transfer files over SSH",
364
+ "Run a command or transfer files/directories on a REMOTE host over SSH. " +
365
+ "For the LOCAL machine use `bash` (or `sudo_run` for local root) instead — do not use ssh_run for local work. " +
366
+ "Requires a `host`. Windows/PowerShell shells are best-effort (elevation and PowerShell stream/encoding semantics unsupported). " +
367
+ "Set `sudo: true` to run the command as root on the remote machine. " +
368
+ 'For transfer, set `action: "file"`, `direction`, `source`, `destination`, and optional `recursive` — ' +
369
+ "transfers may overwrite the destination. " +
370
+ 'To discover hosts without reading `~/.ssh/config`, use `action: "info"` — omit `host` to list configured aliases, or pass a `host` to get its effective config (no connection). ' +
371
+ "Always provide a clear `reason`.",
372
+ promptSnippet: "Run a remote command, transfer files, or read SSH config over SSH",
314
373
  promptGuidelines: [
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`.',
374
+ 'ssh_run: REMOTE host only — use `bash`/`sudo_run` for the local machine. `host` as `[user@]host[:port]`; `sudo` covers remote POSIX sudo only. For transfer use `action: "file"` with `direction: "upload"|"download"`, `source`, `destination`, optional `recursive` (may overwrite). Use `action: "info"` (no `host` = list aliases, with `host` = its effective config) instead of reading `~/.ssh/config` yourself. Always set `reason`.',
316
375
  ],
317
376
 
318
377
  renderShell: "self",
319
378
 
320
- parameters: Type.Union([
321
- Type.Object({
322
- action: Type.Optional(Type.Literal("command")),
323
- host: Type.String({
324
- description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
379
+ // Single Type.Object (root `type: "object"`) rather than Type.Union — a union
380
+ // serializes to `anyOf` with no root type, which strict OpenAI-compatible
381
+ // providers (e.g. DeepSeek) reject with `type: null`. Conditional fields are
382
+ // optional and normalized/validated at runtime via normalizeOperation.
383
+ parameters: Type.Object({
384
+ action: Type.Optional(
385
+ Type.Union([Type.Literal("command"), Type.Literal("file"), Type.Literal("info")], {
386
+ description:
387
+ '"command" (default) runs a remote command; "file" transfers a file/directory; "info" reports SSH config (no connection) — list configured host aliases, or resolve one host\'s effective config when `host` is given.',
325
388
  }),
326
- command: Type.String({
327
- description: "Command sent to the remote host's configured SSH shell.",
389
+ ),
390
+ host: Type.Optional(
391
+ Type.String({
392
+ description:
393
+ "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`). Required for command/file; for info, omit to list all aliases or set it to resolve one host.",
328
394
  }),
329
- sudo: Type.Optional(
330
- Type.Boolean({
331
- description: "Run the command as root on the remote host via sudo. Default false.",
332
- }),
333
- ),
334
- reason: Type.Optional(
335
- Type.String({
336
- description: "Short plain-English explanation of intent, shown to the user.",
337
- }),
338
- ),
339
- }),
340
- Type.Object({
341
- action: Type.Literal("file"),
342
- host: Type.String({
343
- description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
395
+ ),
396
+ command: Type.Optional(
397
+ Type.String({
398
+ description:
399
+ 'Command sent to the remote host\'s configured SSH shell. Required when action is "command".',
344
400
  }),
345
- direction: Type.Union([Type.Literal("upload"), Type.Literal("download")]),
346
- source: Type.String({
347
- description: "Source path. Local for upload; remote for download.",
401
+ ),
402
+ sudo: Type.Optional(
403
+ Type.Boolean({
404
+ description:
405
+ "Run the command as root on the remote host via sudo. Default false. Command action only.",
348
406
  }),
349
- destination: Type.String({
350
- description: "Destination path. Remote for upload; local for download.",
407
+ ),
408
+ direction: Type.Optional(
409
+ Type.Union([Type.Literal("upload"), Type.Literal("download")], {
410
+ description: 'Transfer direction. Required when action is "file".',
351
411
  }),
352
- recursive: Type.Optional(
353
- Type.Boolean({ description: "Copy a directory recursively. Default false." }),
354
- ),
355
- reason: Type.Optional(
356
- Type.String({
357
- description: "Short plain-English explanation of intent, shown to the user.",
358
- }),
359
- ),
360
- }),
361
- ]),
412
+ ),
413
+ source: Type.Optional(
414
+ Type.String({
415
+ description:
416
+ 'Source path (local for upload, remote for download). Required when action is "file".',
417
+ }),
418
+ ),
419
+ destination: Type.Optional(
420
+ Type.String({
421
+ description:
422
+ 'Destination path (remote for upload, local for download). Required when action is "file".',
423
+ }),
424
+ ),
425
+ recursive: Type.Optional(
426
+ Type.Boolean({
427
+ description: "Copy a directory recursively. Default false. File action only.",
428
+ }),
429
+ ),
430
+ reason: Type.Optional(
431
+ Type.String({
432
+ description: "Short plain-English explanation of intent, shown to the user.",
433
+ }),
434
+ ),
435
+ }),
362
436
 
363
437
  async execute(_toolCallId, params, sig, onUpdate, ctx) {
438
+ // info: read-only SSH config report (no connection, no approval).
439
+ if (params.action === "info") {
440
+ return infoResult(params.host, sig);
441
+ }
442
+
443
+ if (!params.host?.trim()) {
444
+ return {
445
+ content: [{ type: "text", text: "ssh_run failed: host is required" }],
446
+ details: makeDetails("", "", false, params.reason, {
447
+ outcome: "error",
448
+ errorKind: "execution",
449
+ }),
450
+ isError: true,
451
+ };
452
+ }
364
453
  const operation = normalizeOperation(params);
365
454
  const { action, command, destination, direction, reason, recursive, source, sudo } =
366
455
  operation;
@@ -376,6 +465,17 @@ export default function (pi: ExtensionAPI): void {
376
465
  };
377
466
  }
378
467
 
468
+ if (action === "command" && !command.trim()) {
469
+ return {
470
+ content: [{ type: "text", text: "ssh_run failed: command is required" }],
471
+ details: makeDetails(command, params.host, sudo, reason, {
472
+ outcome: "error",
473
+ errorKind: "execution",
474
+ }),
475
+ isError: true,
476
+ };
477
+ }
478
+
379
479
  let spec: HostSpec;
380
480
  try {
381
481
  spec = parseHost(params.host);
@@ -713,9 +813,17 @@ export default function (pi: ExtensionAPI): void {
713
813
  )
714
814
  return text;
715
815
 
816
+ const host = safeOneLine(args.host ?? "");
817
+ if (args.action === "info") {
818
+ text.setText(
819
+ fillToolBackground(
820
+ `${theme.fg("toolTitle", theme.bold("ssh info"))} ${theme.fg("dim", host || "list aliases")}`,
821
+ ),
822
+ );
823
+ return text;
824
+ }
716
825
  const operation = normalizeOperation(args);
717
826
  const command = safeOneLine(operation.command) || "(empty command)";
718
- const host = safeOneLine(args.host);
719
827
  const prefix = operation.sudo ? "sudo " : "";
720
828
  text.setText(
721
829
  fillToolBackground(
package/src/lib.ts CHANGED
@@ -15,8 +15,9 @@
15
15
 
16
16
  import { spawn } from "node:child_process";
17
17
  import { createHash } from "node:crypto";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
18
+ import { globSync, readFileSync } from "node:fs";
19
+ import { homedir, tmpdir } from "node:os";
20
+ import { isAbsolute, join, resolve as resolvePath } from "node:path";
20
21
 
21
22
  export const MAX_OUTPUT_BYTES = 50 * 1024;
22
23
  export const MAX_OUTPUT_LINES = 2000;
@@ -153,6 +154,142 @@ export function resolveSshHost(spec: HostSpec, signal?: AbortSignal): Promise<Ho
153
154
  });
154
155
  }
155
156
 
157
+ // ── Host inventory (info action) ─────────────────────────────────────────────
158
+
159
+ export interface HostAlias {
160
+ alias: string;
161
+ hostname?: string;
162
+ user?: string;
163
+ port?: string;
164
+ proxyJump?: string;
165
+ identityFile?: string;
166
+ }
167
+
168
+ export interface HostInfo {
169
+ hostname?: string;
170
+ user?: string;
171
+ port?: string;
172
+ proxyJump?: string;
173
+ identityFile?: string;
174
+ }
175
+
176
+ /** Fields we surface from an ssh config Host block or `ssh -G` output. */
177
+ const INFO_KEYS: Record<string, keyof HostInfo> = {
178
+ hostname: "hostname",
179
+ user: "user",
180
+ port: "port",
181
+ proxyjump: "proxyJump",
182
+ identityfile: "identityFile",
183
+ };
184
+
185
+ /**
186
+ * Parse `Host` blocks out of an ssh_config text. Returns one entry per alias
187
+ * token (a single `Host a b` line yields two aliases). Wildcard-only patterns
188
+ * (`*`, `?`, `!`) are skipped since they aren't connectable targets. Keys are
189
+ * matched case-insensitively; the first value for a key within a block wins
190
+ * (OpenSSH "first obtained value" semantics).
191
+ */
192
+ export function parseHostAliases(text: string): HostAlias[] {
193
+ const out: HostAlias[] = [];
194
+ let current: HostAlias[] = [];
195
+ for (const raw of text.split("\n")) {
196
+ const line = raw.replace(/#.*$/, "").trim();
197
+ if (!line) continue;
198
+ const [keyRaw, ...rest] = line.split(/\s+/);
199
+ const key = (keyRaw ?? "").toLowerCase();
200
+ const value = rest.join(" ");
201
+ if (key === "host") {
202
+ current = rest.filter((p) => !/[*?!]/.test(p)).map((alias) => ({ alias }));
203
+ out.push(...current);
204
+ } else if (current.length > 0) {
205
+ const field = INFO_KEYS[key];
206
+ if (field && value) {
207
+ for (const entry of current) {
208
+ if (entry[field] === undefined) entry[field] = value;
209
+ }
210
+ }
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /** Expand a Path with a leading `~` and resolve relative Includes against
217
+ * the containing config's directory (OpenSSH semantics). */
218
+ function expandConfigPath(pattern: string, baseDir: string): string {
219
+ let p = pattern;
220
+ if (p.startsWith("~/")) p = join(homedir(), p.slice(2));
221
+ else if (p === "~") p = homedir();
222
+ return isAbsolute(p) ? p : resolvePath(baseDir, p);
223
+ }
224
+
225
+ /**
226
+ * Read an ssh_config file and every file it pulls in via `Include`, returning
227
+ * the concatenated Host aliases. Missing files and glob misses are ignored
228
+ * (OpenSSH tolerates them). `seen` guards against Include cycles.
229
+ */
230
+ export function readSshConfigAliases(
231
+ path = join(homedir(), ".ssh", "config"),
232
+ seen = new Set<string>(),
233
+ ): HostAlias[] {
234
+ if (seen.has(path)) return [];
235
+ seen.add(path);
236
+ let text: string;
237
+ try {
238
+ text = readFileSync(path, "utf8");
239
+ } catch {
240
+ return [];
241
+ }
242
+ const baseDir = join(homedir(), ".ssh");
243
+ const out: HostAlias[] = [];
244
+ for (const raw of text.split("\n")) {
245
+ const line = raw.replace(/#.*$/, "").trim();
246
+ const [keyRaw, ...rest] = line.split(/\s+/);
247
+ if ((keyRaw ?? "").toLowerCase() === "include") {
248
+ for (const pattern of rest) {
249
+ const expanded = expandConfigPath(pattern, baseDir);
250
+ let matches: string[] = [];
251
+ try {
252
+ matches = globSync(expanded);
253
+ } catch {
254
+ matches = [];
255
+ }
256
+ for (const file of matches.sort()) out.push(...readSshConfigAliases(file, seen));
257
+ }
258
+ }
259
+ }
260
+ out.push(...parseHostAliases(text));
261
+ return out;
262
+ }
263
+
264
+ /** Full effective config for one host via `ssh -G` (no connection made). */
265
+ export function parseHostInfo(output: string): HostInfo {
266
+ const info: HostInfo = {};
267
+ for (const line of output.split("\n")) {
268
+ const [keyRaw, ...rest] = line.trim().split(/\s+/);
269
+ const field = INFO_KEYS[(keyRaw ?? "").toLowerCase()];
270
+ const value = rest.join(" ");
271
+ if (field && value && info[field] === undefined) info[field] = value;
272
+ }
273
+ return info;
274
+ }
275
+
276
+ /** Run `ssh -G <host>` and return its effective config. Never connects. */
277
+ export function resolveHostInfo(spec: HostSpec, signal?: AbortSignal): Promise<HostInfo> {
278
+ const args = ["-G"];
279
+ if (spec.port !== undefined) args.push("-p", String(spec.port));
280
+ args.push(hostTarget(spec));
281
+ return new Promise((resolve) => {
282
+ let stdout = "";
283
+ const proc = spawn("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
284
+ proc.stdout.on("data", (c: Buffer) => {
285
+ stdout += c.toString();
286
+ });
287
+ proc.on("error", () => resolve({}));
288
+ proc.on("close", (code) => resolve(code === 0 ? parseHostInfo(stdout) : {}));
289
+ signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
290
+ });
291
+ }
292
+
156
293
  /** Canonical `[user@]host` target string for ssh argv. */
157
294
  export function hostTarget(spec: HostSpec): string {
158
295
  return spec.user ? `${spec.user}@${spec.host}` : spec.host;