fix(ssh-controlmaster): handle read-only ControlPath under bind-mounted ~/.ssh

The devbox bind-mounts ~/.ssh read-only. A user ~/.ssh/config with a per-host
ControlPath under it (the CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) is
unwritable there, so a plain `ssh <host> pwd` exits 255 trying to bind the
master socket — blocking `pi --ssh <host>` with "Could not resolve remote pwd".
A system default cannot override a user's per-host value (SSH first-value-wins),
so this must be handled in the extension.

- controlPathWritable(): expands ~, tests whether the socket's parent dir is
  writable (or missing but creatable via nearest existing ancestor). Pure fs
  check — OS-agnostic, no host-OS detection.
- negotiateMaster / negotiateMasterWithPassword: reuse the system master only
  when its ControlPath is writable; otherwise start our own /tmp master whose
  command-line `-o ControlPath` overrides the user's unwritable path.
- Remote pwd probe: `-o ControlPath=none -o ControlMaster=no` so a read-only
  system ControlPath cannot make the initial probe fail.

No behaviour change for configs without ControlMaster. Updates README.md +
AGENTS.md to match.
This commit is contained in:
2026-06-18 21:59:00 +02:00
parent 357fcc6eca
commit 6f7dca06e8
3 changed files with 90 additions and 20 deletions
+63 -7
View File
@@ -28,9 +28,10 @@
*/
import { spawn } from "node:child_process";
import { writeFile, unlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { constants as fsConstants } from "node:fs";
import { access, writeFile, unlink } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
type BashOperations,
@@ -59,6 +60,43 @@ function ownSocketPath(): string {
return join(tmpdir(), `pi-cm-${process.pid}.sock`);
}
/**
* Is the directory that would hold the ControlMaster socket actually writable?
* The devbox commonly bind-mounts ~/.ssh READ-ONLY, while the user's
* ~/.ssh/config may point ControlPath at ~/.ssh/cm/... — unwritable here.
* Reusing such a system socket path is doomed (the master can't bind, and even
* a plain `ssh` fails), so we detect it and fall back to our own socket in
* /tmp instead. OS-agnostic: it tests the actual filesystem, not the host OS.
*
* Returns true if the socket's parent dir exists and is writable, OR is
* missing but a `mkdir -p` could create it (nearest ancestor is writable).
*/
async function controlPathWritable(controlPath: string): Promise<boolean> {
if (!controlPath) return false;
const expanded = controlPath.replace(/^~(?=\/|$)/, homedir());
const dir = dirname(expanded);
try {
await access(dir, fsConstants.W_OK);
return true; // dir exists and is writable
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") return false; // exists but RO
// Dir is missing: could `mkdir -p` create it? Walk up to the nearest
// existing ancestor and check that it is writable.
let probe = dirname(dir);
for (;;) {
try {
await access(probe, fsConstants.W_OK);
return true;
} catch (e2) {
if ((e2 as NodeJS.ErrnoException).code !== "ENOENT") return false;
const parent = dirname(probe);
if (parent === probe) return false; // reached root, nothing writable
probe = parent;
}
}
}
}
function askpassScriptPath(): string {
return join(tmpdir(), `pi-askpass-${process.pid}.sh`);
}
@@ -163,11 +201,14 @@ async function negotiateMaster(
const cfg = await readSshConfig(remote);
const systemHasMaster = cfg.master === "auto" || cfg.master === "yes";
if (systemHasMaster && cfg.path) {
if (systemHasMaster && cfg.path && (await controlPathWritable(cfg.path))) {
return { socketPath: cfg.path, ownsmaster: false };
}
// No system master — create our own
// Either no system master, or the system ControlPath is on a read-only mount
// (e.g. ~/.ssh/cm with ~/.ssh bind-mounted RO) — create our own at a writable
// /tmp socket. Our `-o ControlPath` on the command line overrides the user's
// unwritable path, so this works regardless of ~/.ssh/config.
const socketPath = ownSocketPath();
await startControlMaster(remote, socketPath);
return { socketPath, ownsmaster: true };
@@ -180,11 +221,13 @@ async function negotiateMasterWithPassword(
const cfg = await readSshConfig(remote);
const systemHasMaster = cfg.master === "auto" || cfg.master === "yes";
if (systemHasMaster && cfg.path) {
if (systemHasMaster && cfg.path && (await controlPathWritable(cfg.path))) {
// System master handles auth on its own — password flag is ignored
return { socketPath: cfg.path, ownsmaster: false };
}
// No usable system master (none configured, or ControlPath is read-only) —
// create our own at a writable /tmp socket.
const socketPath = ownSocketPath();
await startControlMasterWithPassword(remote, socketPath, password);
return { socketPath, ownsmaster: true };
@@ -403,7 +446,20 @@ export default function (pi: ExtensionAPI) {
[remote, remoteCwd] = arg.split(":");
} else {
remote = arg;
remoteCwd = await run(["ssh", remote, "pwd"]).catch((e) => {
// Resolve remote $HOME with a DIRECT connection (ControlPath=none): the
// user's ~/.ssh/config may set ControlMaster auto + a ControlPath on a
// 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}`);
});
}