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
+11 -2
View File
@@ -69,20 +69,29 @@ the plain `ssh.ts` example which opens a new connection per tool call.
**Usage:** **Usage:**
```bash ```bash
# Remote home dir resolved automatically via pwd # Key-based auth (normal)
pi --ssh user@192.168.1.10 pi --ssh user@192.168.1.10
# Explicit remote path (skips the initial pwd call) # Explicit remote path (skips the initial pwd call)
pi --ssh root@proxmox-node:/etc/pve 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 # Try without modifying your global install
pi -e ~/src/src_local/pi-extensions/extensions/ssh-controlmaster.ts --ssh user@host pi -e ~/src/src_local/pi-extensions/extensions/ssh-controlmaster.ts --ssh user@host
``` ```
**Requirements:** **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 - `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-<pid>.sh`, `chmod 700`) which is deleted
> immediately after the master is established.
**How it works:** **How it works:**
1. On `session_start`, runs `ssh -G <host>` to read the effective config for that host 1. On `session_start`, runs `ssh -G <host>` to read the effective config for that host
+93 -2
View File
@@ -15,11 +15,20 @@
* pi --ssh user@host:/remote/path * pi --ssh user@host:/remote/path
* *
* Requirements: * 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 * - 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 { spawn } from "node:child_process";
import { writeFile, unlink } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -50,6 +59,49 @@ function ownSocketPath(): string {
return join(tmpdir(), `pi-cm-${process.pid}.sock`); 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. */ /** Run a one-shot command, return stdout as string. Rejects on non-zero exit. */
function run(args: string[]): Promise<string> { function run(args: string[]): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -121,6 +173,23 @@ async function negotiateMaster(
return { socketPath, ownsmaster: true }; 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> { function startControlMaster(remote: string, socketPath: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// -fN: fork to background after auth, don't run a remote command // -fN: fork to background after auth, don't run a remote command
@@ -260,6 +329,12 @@ export default function (pi: ExtensionAPI) {
type: "string", 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 localCwd = process.cwd();
const localRead = createReadTool(localCwd); const localRead = createReadTool(localCwd);
const localWrite = createWriteTool(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…`)); 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 socketPath: string;
let ownsmaster: boolean; let ownsmaster: boolean;
try { try {
({ socketPath, ownsmaster } = await negotiateMaster(remote)); ({ socketPath, ownsmaster } =
password !== undefined
? await negotiateMasterWithPassword(remote, password)
: await negotiateMaster(remote));
} catch (err) { } catch (err) {
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("error", `SSH: ${remote} ✗ failed`)); ctx.ui.setStatus("ssh", ctx.ui.theme.fg("error", `SSH: ${remote} ✗ failed`));
ctx.ui.notify(`SSH ControlMaster failed: ${err}`, "error"); ctx.ui.notify(`SSH ControlMaster failed: ${err}`, "error");