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:**
|
**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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user