fix(ssh-controlmaster): prevent session_start from hanging on unreachable host
pi awaits session_start before the agent loop accepts input, so a blocking ssh call there presents as "TUI is up but prompts are silently ignored". The remote pwd probe and ControlMaster start had no ConnectTimeout, no BatchMode, and no wall-clock cap, so an unreachable host (classic case: `pi-dev --ssh <lan-host>` runs pi *inside* the devbox container, which has no route to LAN hosts without a ProxyJump) blocked startup on the OS TCP timeout (~75s+) while silently swallowing keystrokes. Hardening: - CONNECT_OPTS (ConnectTimeout=8 + ServerAlive keepalives) on every ssh invocation: pwd probe, master start (both key and password paths), sshExec, and bash exec. - run() gains a timeoutMs option (kills child + rejects); pwd probe and both master-start paths bounded by STARTUP_TIMEOUT_MS (15s). - BATCH_OPTS (BatchMode=yes) on key-auth calls so ssh never waits on a /dev/tty password/passphrase/host-key prompt behind the TUI; omitted under --ssh-ask-pass so the SSH_ASKPASS path still works. askPass flag is now read before the probe to pick the right opts. - Probe failure sets an "unreachable" status + error toast and returns, instead of throwing an unhandled rejection from the handler. Docs: README "How it works" + AGENTS.md technical note updated.
This commit is contained in:
@@ -55,6 +55,22 @@ interface SshState {
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// SSH options that bound connection setup and detect dead links, so neither the
|
||||
// startup probe nor a runtime tool call can hang the UI indefinitely. An
|
||||
// unreachable host (e.g. a LAN target with no route from inside a container)
|
||||
// now fails fast instead of blocking on the OS TCP timeout (~75s+).
|
||||
const CONNECT_OPTS = [
|
||||
"-o", "ConnectTimeout=8", // cap TCP/handshake setup
|
||||
"-o", "ServerAliveInterval=5", // probe a dead link rather than block forever
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
];
|
||||
// Key-auth only: refuse any tty/askpass prompt so ssh can never silently wait
|
||||
// on /dev/tty behind pi's TUI. NOT used with --ssh-ask-pass (would disable the
|
||||
// SSH_ASKPASS password path).
|
||||
const BATCH_OPTS = ["-o", "BatchMode=yes"];
|
||||
// Wall-clock cap for ssh calls made during session_start.
|
||||
const STARTUP_TIMEOUT_MS = 15000;
|
||||
|
||||
function ownSocketPath(): string {
|
||||
// Keep path short — macOS has a ~104-char Unix socket path limit
|
||||
return join(tmpdir(), `pi-cm-${process.pid}.sock`);
|
||||
@@ -118,7 +134,7 @@ async function startControlMasterWithPassword(
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"ssh",
|
||||
["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", remote],
|
||||
["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, remote],
|
||||
{
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
@@ -129,8 +145,13 @@ async function startControlMasterWithPassword(
|
||||
},
|
||||
},
|
||||
);
|
||||
child.on("error", reject);
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`ControlMaster (password) start timed out after ${STARTUP_TIMEOUT_MS}ms`));
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
child.on("error", (e) => { clearTimeout(timer); reject(e); });
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`ControlMaster exited with code ${code}`));
|
||||
});
|
||||
@@ -140,16 +161,27 @@ async function startControlMasterWithPassword(
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a one-shot command, return stdout as string. Rejects on non-zero exit. */
|
||||
function run(args: string[]): Promise<string> {
|
||||
/**
|
||||
* Run a one-shot command, return stdout as string. Rejects on non-zero exit.
|
||||
* Pass `timeoutMs` to guarantee the call cannot hang forever — on timeout the
|
||||
* child is killed and the promise rejects.
|
||||
*/
|
||||
function run(args: string[], opts: { timeoutMs?: number } = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(args[0], args.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const out: Buffer[] = [];
|
||||
const err: Buffer[] = [];
|
||||
const timer = opts.timeoutMs
|
||||
? setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`${args.join(" ")} timed out after ${opts.timeoutMs}ms`));
|
||||
}, opts.timeoutMs)
|
||||
: undefined;
|
||||
child.stdout.on("data", (d: Buffer) => out.push(d));
|
||||
child.stderr.on("data", (d: Buffer) => err.push(d));
|
||||
child.on("error", reject);
|
||||
child.on("error", (e) => { if (timer) clearTimeout(timer); reject(e); });
|
||||
child.on("close", (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (code === 0) resolve(Buffer.concat(out).toString().trim());
|
||||
else reject(new Error(`${args.join(" ")} exited ${code}: ${Buffer.concat(err).toString().trim()}`));
|
||||
});
|
||||
@@ -237,13 +269,20 @@ 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
|
||||
// ControlPersist=yes: keep master alive in the background indefinitely
|
||||
// BatchMode=yes: key-auth only — never wait on a tty password/passphrase
|
||||
// prompt (which would hang invisibly behind pi's TUI).
|
||||
const child = spawn(
|
||||
"ssh",
|
||||
["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", remote],
|
||||
["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, ...BATCH_OPTS, remote],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
child.on("error", reject);
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`ControlMaster start timed out after ${STARTUP_TIMEOUT_MS}ms`));
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
child.on("error", (e) => { clearTimeout(timer); reject(e); });
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`ControlMaster exited with code ${code}`));
|
||||
});
|
||||
@@ -272,7 +311,7 @@ function sshExec(remote: string, socketPath: string, command: string): Promise<B
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"ssh",
|
||||
["-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, remote, command],
|
||||
["-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, command],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
const out: Buffer[] = [];
|
||||
@@ -341,7 +380,7 @@ function createRemoteBashOps(
|
||||
const cmd = `cd ${JSON.stringify(r(cwd))} && ${command}`;
|
||||
const child = spawn(
|
||||
"ssh",
|
||||
["-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, remote, cmd],
|
||||
["-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, cmd],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let timedOut = false;
|
||||
@@ -442,6 +481,10 @@ export default function (pi: ExtensionAPI) {
|
||||
let remote: string;
|
||||
let remoteCwd: string;
|
||||
|
||||
// Read this up front: it decides whether the pwd probe may use BatchMode
|
||||
// (key auth) or must allow the SSH_ASKPASS password path.
|
||||
const askPass = pi.getFlag("ssh-ask-pass") as boolean;
|
||||
|
||||
if (arg.includes(":")) {
|
||||
[remote, remoteCwd] = arg.split(":");
|
||||
} else {
|
||||
@@ -451,22 +494,35 @@ export default function (pi: ExtensionAPI) {
|
||||
// read-only mount, which makes a plain `ssh pwd` fail trying to bind the
|
||||
// master socket. ControlPath=none sidesteps multiplexing for this one
|
||||
// probe; the real (writable) master is established by negotiateMaster().
|
||||
remoteCwd = await run([
|
||||
"ssh",
|
||||
"-o",
|
||||
"ControlPath=none",
|
||||
"-o",
|
||||
"ControlMaster=no",
|
||||
remote,
|
||||
"pwd",
|
||||
]).catch((e) => {
|
||||
throw new Error(`Could not resolve remote pwd: ${e.message}`);
|
||||
});
|
||||
//
|
||||
// Hardened: CONNECT_OPTS + a wall-clock timeout guarantee this can't hang
|
||||
// the UI when the host is unreachable (e.g. a LAN target with no route
|
||||
// from inside a container). BatchMode is added for key auth so ssh never
|
||||
// waits silently on a /dev/tty prompt; it is omitted under --ssh-ask-pass.
|
||||
const probeOpts = [
|
||||
"-o", "ControlPath=none",
|
||||
"-o", "ControlMaster=no",
|
||||
...CONNECT_OPTS,
|
||||
...(askPass ? [] : BATCH_OPTS),
|
||||
];
|
||||
try {
|
||||
remoteCwd = await run(["ssh", ...probeOpts, remote, "pwd"], {
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
} catch (e) {
|
||||
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("error", `SSH: ${remote} ✗ unreachable`));
|
||||
ctx.ui.notify(
|
||||
`SSH: could not reach ${remote} (${(e as Error).message}). ` +
|
||||
`Check the host is routable from here — e.g. \`pi --ssh\` from inside a ` +
|
||||
`container needs a ProxyJump to LAN hosts.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user