@xynogen/pix-ssh 0.3.3 → 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 +8 -1
- package/package.json +2 -1
- package/src/index.ts +179 -71
- package/src/lib.ts +139 -2
- package/src/lib.test.ts +0 -320
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
|
+
"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",
|
|
@@ -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/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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
304
|
-
"
|
|
305
|
-
"
|
|
306
|
-
"
|
|
307
|
-
|
|
308
|
-
"
|
|
309
|
-
|
|
310
|
-
"
|
|
311
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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 {
|
|
19
|
-
import {
|
|
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;
|
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
|
-
});
|