ssh-controlmaster: add --ssh-ask-pass flag for password auth

This commit is contained in:
Joakim Persson
2026-05-05 23:06:52 +02:00
parent 9843a1b327
commit 96dee97094
2 changed files with 104 additions and 4 deletions
+93 -2
View File
@@ -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<void> {
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<void>((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<string> {
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<void> {
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");