@xynogen/pix-ssh 0.4.0 → 0.4.2
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 +1 -1
- package/package.json +1 -1
- package/src/index.ts +17 -5
- package/src/lib.ts +27 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Lets the agent discover hosts without reading `~/.ssh/config` itself. Read-only:
|
|
|
26
26
|
### Authentication
|
|
27
27
|
|
|
28
28
|
- **SSH login** — tries key/agent/existing-master auth first via a `BatchMode=yes` probe. If that succeeds, no login password is needed. If it fails, a masked overlay (`●` per character) collects the login password, fed to `sshpass -e` through the child's `SSHPASS` env var — never as an argv (no `ps` leak), never written to disk.
|
|
29
|
-
- **Remote sudo** (`sudo: true`) — a separate masked prompt collects the remote sudo password, piped to the remote `sudo -S -p ''` on stdin, so it travels inside the encrypted SSH channel, not as an argv.
|
|
29
|
+
- **Remote sudo** (`sudo: true`) — when the SSH connection is passwordless, `ssh_run` first probes `sudo -n true`; if the remote sudoers is `NOPASSWD` the overlay shows a plain Allow/Deny with no sudo prompt. Otherwise a separate masked prompt collects the remote sudo password, piped to the remote `sudo -S -p ''` on stdin, so it travels inside the encrypted SSH channel, not as an argv.
|
|
30
30
|
|
|
31
31
|
Both passwords are cached **in-memory per host** for the session (keyed by `user@host:port`), never persisted. A wrong login password drops the login cache; a wrong sudo password drops the sudo cache — the next call re-prompts.
|
|
32
32
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
30
30
|
import { FG_DIM, RST, resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
|
|
31
31
|
import { MAX_PREVIEW_LINES } from "@xynogen/pix-pretty/config";
|
|
32
32
|
import { type OverlayResult, showOverlay } from "@xynogen/pix-pretty/gate-overlay";
|
|
33
|
+
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
33
34
|
import { renderBashOutput } from "@xynogen/pix-pretty/renderers";
|
|
34
35
|
import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-pretty/types";
|
|
35
36
|
import {
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
MAX_OUTPUT_LINES,
|
|
66
67
|
parseHost,
|
|
67
68
|
probeKeyAuth,
|
|
69
|
+
probeSudoNoPassword,
|
|
68
70
|
readSshConfigAliases,
|
|
69
71
|
resolveHostInfo,
|
|
70
72
|
resolveSshHost,
|
|
@@ -535,7 +537,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
535
537
|
const keyOk = probe === "ok";
|
|
536
538
|
// Which passwords must the overlay collect this call?
|
|
537
539
|
const needLogin = probe === "auth" && !creds.loginPassword;
|
|
538
|
-
|
|
540
|
+
// Remote sudo password only when NOT already cached, NOT NOPASSWD, and
|
|
541
|
+
// the SSH connection is passwordless (so the probe can open its own
|
|
542
|
+
// channel without prompting). When login still needs a password we
|
|
543
|
+
// can't probe yet, so fall back to prompting for the sudo password.
|
|
544
|
+
const sudoNoPassword =
|
|
545
|
+
sudo && !creds.sudoPassword && !needLogin && (keyOk || Boolean(creds.loginPassword))
|
|
546
|
+
? await probeSudoNoPassword(spec, controlPath, sig)
|
|
547
|
+
: false;
|
|
548
|
+
const needSudo = sudo && !creds.sudoPassword && !sudoNoPassword;
|
|
539
549
|
// The overlay stage pipeline: any password we still need is prompted
|
|
540
550
|
// (login first, then sudo). Confirm-only when nothing is missing.
|
|
541
551
|
const promptFor: ("login" | "sudo")[] = [
|
|
@@ -598,10 +608,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
598
608
|
if (promptFor.length === 0) {
|
|
599
609
|
return showOverlay(ctx.ui, {
|
|
600
610
|
mode: "confirm",
|
|
611
|
+
icon: action === "file" ? icon("warn") : icon("lock"),
|
|
601
612
|
title:
|
|
602
613
|
action === "file"
|
|
603
|
-
?
|
|
604
|
-
: "
|
|
614
|
+
? `SSH ${direction === "download" ? "Download" : "Upload"}`
|
|
615
|
+
: "SSH Command Request",
|
|
605
616
|
body,
|
|
606
617
|
accent: sudo ? "error" : action === "file" ? "warning" : "accent",
|
|
607
618
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
@@ -626,10 +637,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
626
637
|
const label = stage === "login" ? "SSH login password" : "Remote sudo password";
|
|
627
638
|
last = await showOverlay(ctx.ui, {
|
|
628
639
|
mode: "sudo",
|
|
640
|
+
icon: action === "file" ? icon("warn") : icon("lock"),
|
|
629
641
|
title:
|
|
630
642
|
action === "file"
|
|
631
|
-
?
|
|
632
|
-
: "
|
|
643
|
+
? `SSH ${direction === "download" ? "Download" : "Upload"}`
|
|
644
|
+
: "SSH Command Request",
|
|
633
645
|
body: [...body, `Enter: ${label}`],
|
|
634
646
|
accent: sudo ? "error" : action === "file" ? "warning" : "accent",
|
|
635
647
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
package/src/lib.ts
CHANGED
|
@@ -454,6 +454,33 @@ export function probeKeyAuth(
|
|
|
454
454
|
});
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Probe whether remote sudo runs without a password (NOPASSWD sudoers) via
|
|
459
|
+
* `sudo -n true`. Returns true when no sudo password is needed. Requires a
|
|
460
|
+
* passwordless SSH connection (key/agent/existing-master or a cached login
|
|
461
|
+
* password) — the probe opens its own channel with `BatchMode=yes`, so callers
|
|
462
|
+
* must only probe when SSH login auth already succeeds without prompting.
|
|
463
|
+
*/
|
|
464
|
+
export function probeSudoNoPassword(
|
|
465
|
+
spec: HostSpec,
|
|
466
|
+
controlPath: string,
|
|
467
|
+
signal?: AbortSignal,
|
|
468
|
+
): Promise<boolean> {
|
|
469
|
+
const args = [
|
|
470
|
+
...baseSshArgs(spec, controlPath),
|
|
471
|
+
"-o",
|
|
472
|
+
"BatchMode=yes",
|
|
473
|
+
hostTarget(spec),
|
|
474
|
+
"sudo -n true",
|
|
475
|
+
];
|
|
476
|
+
return new Promise((resolve) => {
|
|
477
|
+
const proc = spawn("ssh", args, { stdio: ["ignore", "ignore", "ignore"] });
|
|
478
|
+
proc.on("error", () => resolve(false));
|
|
479
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
480
|
+
signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
457
484
|
/** Connection/DNS-level failure (not an auth rejection) — a password can't fix it. */
|
|
458
485
|
export function isUnreachable(stderr: string): boolean {
|
|
459
486
|
const lower = stderr.toLowerCase();
|