@xynogen/pix-ssh 0.4.1 → 0.5.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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.4.1",
3
+ "version": "0.5.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
@@ -66,6 +66,8 @@ import {
66
66
  MAX_OUTPUT_LINES,
67
67
  parseHost,
68
68
  probeKeyAuth,
69
+ probePasswordAuth,
70
+ probeSudoNoPassword,
69
71
  readSshConfigAliases,
70
72
  resolveHostInfo,
71
73
  resolveSshHost,
@@ -100,6 +102,23 @@ function cacheKey(spec: HostSpec): string {
100
102
  return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
101
103
  }
102
104
 
105
+ /**
106
+ * Overlay password validator for a prompt stage. The login stage tests the
107
+ * password against the host (wrong one re-prompts up to MAX_PASSWORD_ATTEMPTS);
108
+ * the sudo stage can't be probed before login, so it only rejects a blank entry
109
+ * and surfaces a real failure on the run.
110
+ */
111
+ export function validatorFor(
112
+ stage: "login" | "sudo",
113
+ spec: HostSpec,
114
+ controlPath: string,
115
+ sig?: AbortSignal,
116
+ ): (pw: string) => Promise<boolean> {
117
+ if (stage !== "login") return (pw) => Promise.resolve(pw.trim().length > 0);
118
+ return (pw) =>
119
+ pw.trim().length === 0 ? Promise.resolve(false) : probePasswordAuth(spec, controlPath, pw, sig);
120
+ }
121
+
103
122
  type SshOutcome =
104
123
  | "awaiting-approval"
105
124
  | "running"
@@ -536,7 +555,15 @@ export default function (pi: ExtensionAPI): void {
536
555
  const keyOk = probe === "ok";
537
556
  // Which passwords must the overlay collect this call?
538
557
  const needLogin = probe === "auth" && !creds.loginPassword;
539
- const needSudo = sudo && !creds.sudoPassword;
558
+ // Remote sudo password only when NOT already cached, NOT NOPASSWD, and
559
+ // the SSH connection is passwordless (so the probe can open its own
560
+ // channel without prompting). When login still needs a password we
561
+ // can't probe yet, so fall back to prompting for the sudo password.
562
+ const sudoNoPassword =
563
+ sudo && !creds.sudoPassword && !needLogin && (keyOk || Boolean(creds.loginPassword))
564
+ ? await probeSudoNoPassword(spec, controlPath, sig)
565
+ : false;
566
+ const needSudo = sudo && !creds.sudoPassword && !sudoNoPassword;
540
567
  // The overlay stage pipeline: any password we still need is prompted
541
568
  // (login first, then sudo). Confirm-only when nothing is missing.
542
569
  const promptFor: ("login" | "sudo")[] = [
@@ -638,7 +665,11 @@ export default function (pi: ExtensionAPI): void {
638
665
  timeoutMs: PROMPT_TIMEOUT_MS,
639
666
  maxPasswordAttempts: MAX_PASSWORD_ATTEMPTS,
640
667
  passwordLabel: `${label}:`,
641
- validatePassword: (pw) => Promise.resolve(pw.trim().length > 0),
668
+ // Login stage: actually test the password against the host so a
669
+ // wrong one re-prompts (up to MAX_PASSWORD_ATTEMPTS), mirroring
670
+ // pix-sudo. Sudo stage can't be validated until login succeeds, so
671
+ // it keeps the non-empty check and surfaces failures on the run.
672
+ validatePassword: validatorFor(stage, spec, controlPath, sig),
642
673
  choices: [
643
674
  {
644
675
  value: "yes",
package/src/lib.ts CHANGED
@@ -454,6 +454,84 @@ export function probeKeyAuth(
454
454
  });
455
455
  }
456
456
 
457
+ /**
458
+ * Validate an SSH *login* password by attempting one real password auth. Forces
459
+ * password auth (no pubkey/agent) with a single prompt, fed via `sshpass -e`
460
+ * (env, never argv). Returns true only on a clean login; an auth rejection
461
+ * returns false so the overlay can re-prompt. Unreachable/spawn errors return
462
+ * true — a password can't fix connectivity, so defer to the real run instead of
463
+ * burning retry attempts. ponytail: each call is one fresh connection (its own
464
+ * MaxAuthTries counter, NumberOfPasswordPrompts=1), so retries don't stack
465
+ * toward a per-connection lockout; a host with fail2ban-style IP banning is the
466
+ * ceiling — then drop back to a single prompt.
467
+ */
468
+ export function probePasswordAuth(
469
+ spec: HostSpec,
470
+ controlPath: string,
471
+ password: string,
472
+ signal?: AbortSignal,
473
+ ): Promise<boolean> {
474
+ const args = [
475
+ "-e",
476
+ "ssh",
477
+ ...baseSshArgs(spec, controlPath),
478
+ "-o",
479
+ "BatchMode=no",
480
+ "-o",
481
+ "PubkeyAuthentication=no",
482
+ "-o",
483
+ "PreferredAuthentications=password",
484
+ "-o",
485
+ "NumberOfPasswordPrompts=1",
486
+ hostTarget(spec),
487
+ "true",
488
+ ];
489
+ return new Promise((resolve) => {
490
+ let stderr = "";
491
+ const proc = spawn("sshpass", args, {
492
+ stdio: ["ignore", "ignore", "pipe"],
493
+ env: { ...process.env, SSHPASS: password },
494
+ });
495
+ proc.stderr.on("data", (c: Buffer) => {
496
+ stderr += c.toString();
497
+ });
498
+ proc.on("error", () => resolve(true));
499
+ proc.on("close", (code) => {
500
+ if (code === 0) return resolve(true);
501
+ if (isUnreachable(stderr)) return resolve(true);
502
+ resolve(false);
503
+ });
504
+ signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
505
+ });
506
+ }
507
+
508
+ /**
509
+ * Probe whether remote sudo runs without a password (NOPASSWD sudoers) via
510
+ * `sudo -n true`. Returns true when no sudo password is needed. Requires a
511
+ * passwordless SSH connection (key/agent/existing-master or a cached login
512
+ * password) — the probe opens its own channel with `BatchMode=yes`, so callers
513
+ * must only probe when SSH login auth already succeeds without prompting.
514
+ */
515
+ export function probeSudoNoPassword(
516
+ spec: HostSpec,
517
+ controlPath: string,
518
+ signal?: AbortSignal,
519
+ ): Promise<boolean> {
520
+ const args = [
521
+ ...baseSshArgs(spec, controlPath),
522
+ "-o",
523
+ "BatchMode=yes",
524
+ hostTarget(spec),
525
+ "sudo -n true",
526
+ ];
527
+ return new Promise((resolve) => {
528
+ const proc = spawn("ssh", args, { stdio: ["ignore", "ignore", "ignore"] });
529
+ proc.on("error", () => resolve(false));
530
+ proc.on("close", (code) => resolve(code === 0));
531
+ signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
532
+ });
533
+ }
534
+
457
535
  /** Connection/DNS-level failure (not an auth rejection) — a password can't fix it. */
458
536
  export function isUnreachable(stderr: string): boolean {
459
537
  const lower = stderr.toLowerCase();