ssh-controlmaster: add --ssh-ask-pass flag for password auth
This commit is contained in:
@@ -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-<pid>.sh`, `chmod 700`) which is deleted
|
||||
> immediately after the master is established.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. On `session_start`, runs `ssh -G <host>` to read the effective config for that host
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user