@xynogen/pix-ssh 0.5.1 → 0.5.5
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 +9 -3
- package/package.json +3 -3
- package/src/index.ts +36 -3
- package/src/lib.ts +56 -43
package/README.md
CHANGED
|
@@ -49,16 +49,22 @@ Completed calls collapse after the configured Pix delay into a status row such a
|
|
|
49
49
|
pi install npm:@xynogen/pix-ssh
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
> Standalone
|
|
52
|
+
> Standalone and opt-in. [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core) does not bundle it. Remote execution over SSH is a networked, privileged capability.
|
|
53
53
|
|
|
54
54
|
## Full distro
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
|
+
# Linux / macOS
|
|
59
60
|
curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
|
|
60
61
|
```
|
|
61
62
|
|
|
63
|
+
```powershell
|
|
64
|
+
# Windows
|
|
65
|
+
irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
|
|
66
|
+
```
|
|
67
|
+
|
|
62
68
|
## License
|
|
63
69
|
|
|
64
|
-
MIT
|
|
70
|
+
MIT. See [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-ssh",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"typebox": "^1.1.38",
|
|
41
40
|
"@xynogen/pix-pretty": "^1.25.0",
|
|
42
|
-
"@xynogen/pix-runtime": "^0.
|
|
41
|
+
"@xynogen/pix-runtime": "^0.12.2"
|
|
43
42
|
},
|
|
44
43
|
"peerDependencies": {
|
|
44
|
+
"typebox": "*",
|
|
45
45
|
"@earendil-works/pi-coding-agent": "*",
|
|
46
46
|
"@earendil-works/pi-tui": "*"
|
|
47
47
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
dotJoin,
|
|
38
38
|
fillToolBackground,
|
|
39
39
|
frameToolResult,
|
|
40
|
+
getErrorMessage,
|
|
40
41
|
getTextContent,
|
|
41
42
|
hideCollapsedToolCall,
|
|
42
43
|
normalizeLineEndings,
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
} from "@xynogen/pix-pretty/utils";
|
|
50
51
|
import { SPINNER } from "@xynogen/pix-pretty/widget-format";
|
|
51
52
|
import { getUnattendedMode, withAgentBlock } from "@xynogen/pix-runtime";
|
|
53
|
+
import { requireTool } from "@xynogen/pix-runtime/binaries";
|
|
52
54
|
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
53
55
|
import { Type } from "typebox";
|
|
54
56
|
import {
|
|
@@ -351,6 +353,22 @@ function formatAliasList(aliases: HostAlias[]): string {
|
|
|
351
353
|
* given, otherwise the alias inventory from ~/.ssh/config. Read-only.
|
|
352
354
|
* Details carry `_type: "sshInfo"` so renderResult falls to its generic
|
|
353
355
|
* plain-text branch (no collapse, no exit-code framing). */
|
|
356
|
+
/**
|
|
357
|
+
* The pix-runtime lookup message for the first missing binary this action
|
|
358
|
+
* needs (ssh, plus scp for file transfers), or undefined when all resolve.
|
|
359
|
+
* sshpass is checked lazily: it only matters when a login password is used.
|
|
360
|
+
*/
|
|
361
|
+
export function missingSshTools(action: "command" | "file"): string | undefined {
|
|
362
|
+
for (const name of action === "file" ? ["ssh", "scp"] : ["ssh"]) {
|
|
363
|
+
try {
|
|
364
|
+
requireTool(name);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
return getErrorMessage(err);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
354
372
|
async function infoResult(host: string | undefined, sig?: AbortSignal) {
|
|
355
373
|
const details = { _type: "sshInfo" as const };
|
|
356
374
|
if (host?.trim()) {
|
|
@@ -358,7 +376,7 @@ async function infoResult(host: string | undefined, sig?: AbortSignal) {
|
|
|
358
376
|
try {
|
|
359
377
|
spec = parseHost(host);
|
|
360
378
|
} catch (err) {
|
|
361
|
-
const msg =
|
|
379
|
+
const msg = getErrorMessage(err);
|
|
362
380
|
return {
|
|
363
381
|
content: [{ type: "text" as const, text: `ssh_run failed: ${msg}` }],
|
|
364
382
|
details,
|
|
@@ -379,6 +397,7 @@ async function infoResult(host: string | undefined, sig?: AbortSignal) {
|
|
|
379
397
|
export default function (pi: ExtensionAPI): void {
|
|
380
398
|
pi.registerTool({
|
|
381
399
|
name: "ssh_run",
|
|
400
|
+
...({ exposure: "deferred" } as const),
|
|
382
401
|
label: "Run over SSH",
|
|
383
402
|
description:
|
|
384
403
|
"Run a command or transfer files/directories on a REMOTE host over SSH. " +
|
|
@@ -496,11 +515,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
496
515
|
};
|
|
497
516
|
}
|
|
498
517
|
|
|
518
|
+
// Fail up front with pix's install hint: the probes below treat a spawn
|
|
519
|
+
// error as "unreachable", which would hide a missing ssh/scp.
|
|
520
|
+
const missingTool = missingSshTools(action);
|
|
521
|
+
if (missingTool) {
|
|
522
|
+
return {
|
|
523
|
+
content: [{ type: "text", text: `ssh_run failed: ${missingTool}` }],
|
|
524
|
+
details: makeDetails(command, params.host, sudo, reason, {
|
|
525
|
+
outcome: "error",
|
|
526
|
+
errorKind: "execution",
|
|
527
|
+
}),
|
|
528
|
+
isError: true,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
499
532
|
let spec: HostSpec;
|
|
500
533
|
try {
|
|
501
534
|
spec = parseHost(params.host);
|
|
502
535
|
} catch (err) {
|
|
503
|
-
const msg =
|
|
536
|
+
const msg = getErrorMessage(err);
|
|
504
537
|
return {
|
|
505
538
|
content: [{ type: "text", text: `ssh_run failed: ${msg}` }],
|
|
506
539
|
details: makeDetails(command, params.host, sudo, reason, {
|
|
@@ -750,7 +783,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
750
783
|
...(sig ? { signal: sig } : {}),
|
|
751
784
|
});
|
|
752
785
|
} catch (err) {
|
|
753
|
-
const msg =
|
|
786
|
+
const msg = getErrorMessage(err);
|
|
754
787
|
return {
|
|
755
788
|
content: [{ type: "text", text: `ssh_run failed: ${msg}` }],
|
|
756
789
|
details: makeDetails(
|
package/src/lib.ts
CHANGED
|
@@ -13,14 +13,21 @@
|
|
|
13
13
|
* entered once per session per host.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
16
|
+
import type { spawn } from "node:child_process";
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
18
|
import { globSync, readFileSync } from "node:fs";
|
|
19
|
-
import { homedir, tmpdir } from "node:os";
|
|
20
19
|
import { isAbsolute, join, resolve as resolvePath } from "node:path";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_MAX_BYTES,
|
|
22
|
+
DEFAULT_MAX_LINES,
|
|
23
|
+
truncateHead,
|
|
24
|
+
} from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { spawnTool } from "@xynogen/pix-runtime/exec";
|
|
26
|
+
import { expandHome, homeDir, tempDir } from "@xynogen/pix-runtime/paths";
|
|
27
|
+
|
|
28
|
+
// Pi's own tool-output limits (50KB / 2000 lines), so every tool caps the same way.
|
|
29
|
+
export const MAX_OUTPUT_BYTES = DEFAULT_MAX_BYTES;
|
|
30
|
+
export const MAX_OUTPUT_LINES = DEFAULT_MAX_LINES;
|
|
24
31
|
|
|
25
32
|
/** Per-host approval TTL (ms) — mirrors sudo's PAM ticket window (~15 min). */
|
|
26
33
|
export const APPROVAL_TTL_MS = 15 * 60_000;
|
|
@@ -144,7 +151,7 @@ export function resolveSshHost(spec: HostSpec, signal?: AbortSignal): Promise<Ho
|
|
|
144
151
|
|
|
145
152
|
return new Promise((resolve) => {
|
|
146
153
|
let stdout = "";
|
|
147
|
-
const proc =
|
|
154
|
+
const proc = spawnTool("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
148
155
|
proc.stdout.on("data", (chunk: Buffer) => {
|
|
149
156
|
stdout += chunk.toString();
|
|
150
157
|
});
|
|
@@ -216,9 +223,7 @@ export function parseHostAliases(text: string): HostAlias[] {
|
|
|
216
223
|
/** Expand a Path with a leading `~` and resolve relative Includes against
|
|
217
224
|
* the containing config's directory (OpenSSH semantics). */
|
|
218
225
|
function expandConfigPath(pattern: string, baseDir: string): string {
|
|
219
|
-
|
|
220
|
-
if (p.startsWith("~/")) p = join(homedir(), p.slice(2));
|
|
221
|
-
else if (p === "~") p = homedir();
|
|
226
|
+
const p = expandHome(pattern);
|
|
222
227
|
return isAbsolute(p) ? p : resolvePath(baseDir, p);
|
|
223
228
|
}
|
|
224
229
|
|
|
@@ -228,7 +233,7 @@ function expandConfigPath(pattern: string, baseDir: string): string {
|
|
|
228
233
|
* (OpenSSH tolerates them). `seen` guards against Include cycles.
|
|
229
234
|
*/
|
|
230
235
|
export function readSshConfigAliases(
|
|
231
|
-
path = join(
|
|
236
|
+
path = join(homeDir(), ".ssh", "config"),
|
|
232
237
|
seen = new Set<string>(),
|
|
233
238
|
): HostAlias[] {
|
|
234
239
|
if (seen.has(path)) return [];
|
|
@@ -239,7 +244,7 @@ export function readSshConfigAliases(
|
|
|
239
244
|
} catch {
|
|
240
245
|
return [];
|
|
241
246
|
}
|
|
242
|
-
const baseDir = join(
|
|
247
|
+
const baseDir = join(homeDir(), ".ssh");
|
|
243
248
|
const out: HostAlias[] = [];
|
|
244
249
|
for (const raw of text.split("\n")) {
|
|
245
250
|
const line = raw.replace(/#.*$/, "").trim();
|
|
@@ -280,7 +285,7 @@ export function resolveHostInfo(spec: HostSpec, signal?: AbortSignal): Promise<H
|
|
|
280
285
|
args.push(hostTarget(spec));
|
|
281
286
|
return new Promise((resolve) => {
|
|
282
287
|
let stdout = "";
|
|
283
|
-
const proc =
|
|
288
|
+
const proc = spawnTool("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
284
289
|
proc.stdout.on("data", (c: Buffer) => {
|
|
285
290
|
stdout += c.toString();
|
|
286
291
|
});
|
|
@@ -300,7 +305,7 @@ export function hostTarget(spec: HostSpec): string {
|
|
|
300
305
|
export function controlPathFor(spec: HostSpec): string {
|
|
301
306
|
const key = `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
302
307
|
const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
303
|
-
return join(
|
|
308
|
+
return join(tempDir(), `pix-ssh-${hash}.sock`);
|
|
304
309
|
}
|
|
305
310
|
|
|
306
311
|
// ── ssh argv construction ────────────────────────────────────────────────────
|
|
@@ -405,17 +410,12 @@ export function truncate(
|
|
|
405
410
|
maxLines = MAX_OUTPUT_LINES,
|
|
406
411
|
maxBytes = MAX_OUTPUT_BYTES,
|
|
407
412
|
): { text: string; truncated: boolean } {
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
let result = kept.join("\n");
|
|
415
|
-
if (Buffer.byteLength(result, "utf8") > maxBytes) {
|
|
416
|
-
result = Buffer.from(result, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
417
|
-
}
|
|
418
|
-
return { text: result, truncated: true };
|
|
413
|
+
const r = truncateHead(text, { maxLines, maxBytes });
|
|
414
|
+
if (!r.firstLineExceedsLimit) return { text: r.content, truncated: r.truncated };
|
|
415
|
+
// One line over the byte cap (minified JSON, a base64 blob): truncateHead keeps
|
|
416
|
+
// nothing. Keep its first maxBytes instead, cut on a UTF-8 boundary.
|
|
417
|
+
const head = Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
418
|
+
return { text: head.replace(/\uFFFD$/, ""), truncated: true };
|
|
419
419
|
}
|
|
420
420
|
|
|
421
421
|
// ── Runners ──────────────────────────────────────────────────────────────────
|
|
@@ -440,7 +440,7 @@ export function probeKeyAuth(
|
|
|
440
440
|
const args = [...baseSshArgs(spec, controlPath), "-o", "BatchMode=yes", hostTarget(spec), "true"];
|
|
441
441
|
return new Promise((resolve) => {
|
|
442
442
|
let stderr = "";
|
|
443
|
-
const proc =
|
|
443
|
+
const proc = spawnTool("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
444
444
|
proc.stderr.on("data", (c: Buffer) => {
|
|
445
445
|
stderr += c.toString();
|
|
446
446
|
});
|
|
@@ -488,7 +488,7 @@ export function probePasswordAuth(
|
|
|
488
488
|
];
|
|
489
489
|
return new Promise((resolve) => {
|
|
490
490
|
let stderr = "";
|
|
491
|
-
const proc =
|
|
491
|
+
const proc = spawnTool("sshpass", args, {
|
|
492
492
|
stdio: ["ignore", "ignore", "pipe"],
|
|
493
493
|
env: { ...process.env, SSHPASS: password },
|
|
494
494
|
});
|
|
@@ -525,7 +525,7 @@ export function probeSudoNoPassword(
|
|
|
525
525
|
"sudo -n true",
|
|
526
526
|
];
|
|
527
527
|
return new Promise((resolve) => {
|
|
528
|
-
const proc =
|
|
528
|
+
const proc = spawnTool("ssh", args, { stdio: ["ignore", "ignore", "ignore"] });
|
|
529
529
|
proc.on("error", () => resolve(false));
|
|
530
530
|
proc.on("close", (code) => resolve(code === 0));
|
|
531
531
|
signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
|
|
@@ -569,29 +569,42 @@ export function runTransfer(
|
|
|
569
569
|
recursive: boolean,
|
|
570
570
|
opts: Pick<RunOptions, "controlPath" | "loginPassword" | "signal">,
|
|
571
571
|
): Promise<SshResult> {
|
|
572
|
-
const
|
|
573
|
-
...baseScpArgs(spec, opts.controlPath, recursive),
|
|
574
|
-
"--",
|
|
575
|
-
...transferArgs(spec, direction, source, destination),
|
|
576
|
-
];
|
|
572
|
+
const endpoint = ["--", ...transferArgs(spec, direction, source, destination)];
|
|
577
573
|
const bin = opts.loginPassword ? "sshpass" : "scp";
|
|
578
|
-
|
|
574
|
+
// Same rule as runSsh: without a password in hand, force BatchMode so scp
|
|
575
|
+
// never opens its own /dev/tty prompt. sshpass path keeps prompts on.
|
|
576
|
+
const args = opts.loginPassword
|
|
577
|
+
? ["-e", "scp", ...baseScpArgs(spec, opts.controlPath, recursive), ...endpoint]
|
|
578
|
+
: [...baseScpArgs(spec, opts.controlPath, recursive), "-o", "BatchMode=yes", ...endpoint];
|
|
579
579
|
const env = opts.loginPassword ? { ...process.env, SSHPASS: opts.loginPassword } : process.env;
|
|
580
580
|
return spawnResult(bin, args, env, opts.signal);
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
-
|
|
583
|
+
/**
|
|
584
|
+
* Build the ssh/sshpass argv for a run.
|
|
585
|
+
*
|
|
586
|
+
* Without a login password, force `BatchMode=yes` so ssh NEVER falls back to a
|
|
587
|
+
* `/dev/tty` password prompt (which leaks into the TUI). A run-time auth failure
|
|
588
|
+
* then returns a clean error the caller surfaces — all interactive password
|
|
589
|
+
* entry goes through the overlay + `sshpass`. With a password, BatchMode stays
|
|
590
|
+
* off so sshpass can answer ssh's own prompt.
|
|
591
|
+
*/
|
|
592
|
+
export function buildRunSshArgs(
|
|
593
|
+
spec: HostSpec,
|
|
594
|
+
command: string,
|
|
595
|
+
opts: Pick<RunOptions, "controlPath" | "loginPassword" | "sudo">,
|
|
596
|
+
): { bin: string; args: string[] } {
|
|
584
597
|
const remote = remoteCommand(command, opts.sudo === true);
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
let bin = "ssh";
|
|
588
|
-
let args = sshArgs;
|
|
589
|
-
let env = process.env;
|
|
598
|
+
const base = baseSshArgs(spec, opts.controlPath);
|
|
590
599
|
if (opts.loginPassword) {
|
|
591
|
-
bin
|
|
592
|
-
args = ["-e", "ssh", ...sshArgs];
|
|
593
|
-
env = { ...process.env, SSHPASS: opts.loginPassword };
|
|
600
|
+
return { bin: "sshpass", args: ["-e", "ssh", ...base, hostTarget(spec), remote] };
|
|
594
601
|
}
|
|
602
|
+
return { bin: "ssh", args: [...base, "-o", "BatchMode=yes", hostTarget(spec), remote] };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function runSsh(spec: HostSpec, command: string, opts: RunOptions): Promise<SshResult> {
|
|
606
|
+
const { bin, args } = buildRunSshArgs(spec, command, opts);
|
|
607
|
+
const env = opts.loginPassword ? { ...process.env, SSHPASS: opts.loginPassword } : process.env;
|
|
595
608
|
|
|
596
609
|
// Remote sudo reads its password from stdin (first line); anything else
|
|
597
610
|
// closes stdin so the remote command sees EOF.
|
|
@@ -608,7 +621,7 @@ function spawnResult(
|
|
|
608
621
|
stdin?: string,
|
|
609
622
|
): Promise<SshResult> {
|
|
610
623
|
return new Promise((resolve, reject) => {
|
|
611
|
-
const proc =
|
|
624
|
+
const proc = spawnTool(bin, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
|
612
625
|
let stdout = "";
|
|
613
626
|
let stderr = "";
|
|
614
627
|
proc.stdout.on("data", (c: Buffer) => {
|