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
+21 -8
View File
@@ -98,11 +98,22 @@ on a remote machine via SSH when `--ssh user@host` is passed.
- **ControlMaster negotiation via `ssh -G`.**
Before starting any connection, `readSshConfig(remote)` runs `ssh -G <host>`
and inspects the `controlmaster` and `controlpath` fields. If the effective
config already has `ControlMaster auto` or `yes`, the system socket is reused
(`ownsmaster: false`). Otherwise, the extension starts its own master at
`/tmp/pi-cm-<pid>.sock` (`ownsmaster: true`). The `session_shutdown` handler
only calls `ssh -O exit` when `ownsmaster` is true — it never tears down a
connection it didn't create.
config already has `ControlMaster auto` or `yes` **and the `ControlPath`
directory is writable** (`controlPathWritable()` expands `~`, then checks the
socket's parent dir exists+writable, or is missing but creatable), the system
socket is reused (`ownsmaster: false`). Otherwise — no system master, **or** a
system `ControlPath` on a read-only mount (the devbox bind-mounts `~/.ssh`
read-only, so a user's `ControlPath ~/.ssh/cm/...` is unwritable) — the
extension starts its own master at `/tmp/pi-cm-<pid>.sock` (`ownsmaster:
true`); its command-line `-o ControlPath` overrides the user's unwritable
path. The `session_shutdown` handler only calls `ssh -O exit` when
`ownsmaster` is true — it never tears down a connection it didn't create.
- **Read-only-`~/.ssh` safe pwd probe.**
The remote working dir is resolved with `ssh -o ControlPath=none -o
ControlMaster=no <remote> pwd` — a direct connection that ignores multiplexing
so a read-only system `ControlPath` (which a plain `ssh … pwd` would try, and
fail, to bind a master socket into) cannot make the initial probe exit 255.
- **Password auth via `--ssh-ask-pass`.**
When the flag is set, `ctx.ui.input()` prompts for a password before
@@ -119,9 +130,11 @@ on a remote machine via SSH when `--ssh user@host` is passed.
when the paths diverge.
- **`ownsmaster` vs system master and `--ssh-ask-pass`.**
If the system already has a ControlMaster configured for the target host,
`--ssh-ask-pass` is silently ignored — the system master handles auth
independently and the socket is just reused.
If the system already has a ControlMaster configured for the target host
*and its `ControlPath` is writable*, `--ssh-ask-pass` is silently ignored —
the system master handles auth independently and the socket is just reused.
When the system `ControlPath` is unwritable (read-only `~/.ssh`) we start our
own master instead, so the password flag is honored on that path.
### `confirm-destructive.ts`