From 96dee97094eb3d522efe29e09ecf3c81b47825fd Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Tue, 5 May 2026 23:06:52 +0200 Subject: [PATCH] ssh-controlmaster: add --ssh-ask-pass flag for password auth --- README.md | 13 ++++- extensions/ssh-controlmaster.ts | 95 ++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0d0cb2e..a32ea1d 100644 --- a/README.md +++ b/README.md @@ -69,20 +69,29 @@ the plain `ssh.ts` example which opens a new connection per tool call. **Usage:** ```bash -# Remote home dir resolved automatically via pwd +# Key-based auth (normal) pi --ssh user@192.168.1.10 # Explicit remote path (skips the initial pwd call) pi --ssh root@proxmox-node:/etc/pve +# Password auth — prompts before connecting +pi --ssh user@host --ssh-ask-pass + # Try without modifying your global install pi -e ~/src/src_local/pi-extensions/extensions/ssh-controlmaster.ts --ssh user@host ``` **Requirements:** -- SSH key-based auth (no password prompts — ControlMaster won't work with interactive auth) +- SSH key-based auth (preferred), or password auth via `--ssh-ask-pass` (see below) - `bash` available on the remote +> **Note on `--ssh-ask-pass`:** The password is prompted via pi's input dialog +> before the SSH connection is opened. Input is **not masked** — the password +> is visible while typing. It is passed to SSH via a temporary `SSH_ASKPASS` +> script (`/tmp/pi-askpass-.sh`, `chmod 700`) which is deleted +> immediately after the master is established. + **How it works:** 1. On `session_start`, runs `ssh -G ` to read the effective config for that host diff --git a/extensions/ssh-controlmaster.ts b/extensions/ssh-controlmaster.ts index 3eb196c..010e7b0 100644 --- a/extensions/ssh-controlmaster.ts +++ b/extensions/ssh-controlmaster.ts @@ -15,11 +15,20 @@ * pi --ssh user@host:/remote/path * * Requirements: - * - SSH key-based auth (no password prompts) + * - SSH key-based auth (preferred) or password auth (see --ssh-ask-pass) * - bash on the remote machine + * + * Password authentication: + * pi --ssh user@host --ssh-ask-pass + * + * Prompts for the password via pi's input dialog before connecting. + * The password is passed to SSH via SSH_ASKPASS (a temp script written to + * /tmp, chmod 700, deleted immediately after the master is established). + * Note: input is NOT masked — the password is visible while typing. */ import { spawn } from "node:child_process"; +import { writeFile, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; @@ -50,6 +59,49 @@ function ownSocketPath(): string { return join(tmpdir(), `pi-cm-${process.pid}.sock`); } +function askpassScriptPath(): string { + return join(tmpdir(), `pi-askpass-${process.pid}.sh`); +} + +/** + * Write a temporary SSH_ASKPASS script that prints the given password, then + * start the ControlMaster with that script set as the askpass helper. + * The script is deleted immediately after the master process exits. + */ +async function startControlMasterWithPassword( + remote: string, + socketPath: string, + password: string, +): Promise { + const scriptPath = askpassScriptPath(); + // Write script before spawning so SSH finds it when it calls askpass + await writeFile(scriptPath, `#!/bin/sh\necho ${JSON.stringify(password)}\n`, { mode: 0o700 }); + try { + await new Promise((resolve, reject) => { + const child = spawn( + "ssh", + ["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", remote], + { + stdio: "ignore", + env: { + ...process.env, + SSH_ASKPASS: scriptPath, + SSH_ASKPASS_REQUIRE: "force", // OpenSSH 8.4+ — no DISPLAY needed + DISPLAY: "dummy", // fallback for older SSH + }, + }, + ); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`ControlMaster exited with code ${code}`)); + }); + }); + } finally { + await unlink(scriptPath).catch(() => {}); // best-effort delete + } +} + /** Run a one-shot command, return stdout as string. Rejects on non-zero exit. */ function run(args: string[]): Promise { return new Promise((resolve, reject) => { @@ -121,6 +173,23 @@ async function negotiateMaster( return { socketPath, ownsmaster: true }; } +async function negotiateMasterWithPassword( + remote: string, + password: string, +): Promise<{ socketPath: string; ownsmaster: boolean }> { + const cfg = await readSshConfig(remote); + const systemHasMaster = cfg.master === "auto" || cfg.master === "yes"; + + if (systemHasMaster && cfg.path) { + // System master handles auth on its own — password flag is ignored + return { socketPath: cfg.path, ownsmaster: false }; + } + + const socketPath = ownSocketPath(); + await startControlMasterWithPassword(remote, socketPath, password); + return { socketPath, ownsmaster: true }; +} + function startControlMaster(remote: string, socketPath: string): Promise { return new Promise((resolve, reject) => { // -fN: fork to background after auth, don't run a remote command @@ -260,6 +329,12 @@ export default function (pi: ExtensionAPI) { type: "string", }); + pi.registerFlag("ssh-ask-pass", { + description: "Prompt for SSH password instead of using key-based auth", + type: "boolean", + default: false, + }); + const localCwd = process.cwd(); const localRead = createReadTool(localCwd); const localWrite = createWriteTool(localCwd); @@ -335,11 +410,27 @@ export default function (pi: ExtensionAPI) { ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH: ${remote} ⟳ connecting…`)); + const askPass = pi.getFlag("ssh-ask-pass") as boolean; + let password: string | undefined; + + if (askPass) { + const input = await ctx.ui.input("SSH password:", ""); + if (input === null) { + ctx.ui.setStatus("ssh", ""); + ctx.ui.notify("SSH connection cancelled", "info"); + return; + } + password = input; + } + let socketPath: string; let ownsmaster: boolean; try { - ({ socketPath, ownsmaster } = await negotiateMaster(remote)); + ({ socketPath, ownsmaster } = + password !== undefined + ? await negotiateMasterWithPassword(remote, password) + : await negotiateMaster(remote)); } catch (err) { ctx.ui.setStatus("ssh", ctx.ui.theme.fg("error", `SSH: ${remote} ✗ failed`)); ctx.ui.notify(`SSH ControlMaster failed: ${err}`, "error");