fix(ssh): survive read-only ~/.ssh ControlPath; render sidecar on all host OSes

Coordinated with the pi-extensions ssh-controlmaster fix (picked up at build via
PI_EXTENSIONS_REF=main), this makes `pi --ssh <host>` and `dssh`/`dscp` robust
to a user ~/.ssh/config whose per-host ControlPath points under the read-only
~/.ssh bind-mount (e.g. `ControlPath ~/.ssh/cm/%r@%h:%p`). A system default can
never override a user's per-host value, so the fix lives in two layers.

- setup-lan-access.sh: always render the writable ~/.ssh-local/config sidecar
  (Host * ControlPath redirect into ~/.ssh-local/cm + Include ~/.ssh/config) on
  EVERY host OS. Previously the script exited early (no-op) on native Linux,
  leaving dssh/dscp broken when ~/.ssh was read-only there too. The host-jump
  block, its key generation, and the authorize hints stay gated on VM-backed
  detection / DEVBOX_LAN_ACCESS=jump (new NEED_JUMP flag).
- Dockerfile.base: document that the /etc/ssh drop-in default cannot override a
  user per-host ControlPath; cross-ref the two handling layers.
- entrypoint-user.sh: correct the now-stale "no-op on native Linux" comment.
- README.md / DOCKER_HUB.md: document read-only-~/.ssh ControlPath handling.

CHANGELOG: v1.1.5 (Fixed + Changed + pi 0.79.6 -> 0.79.7 auto-resolved bump).
This commit is contained in:
Joakim Persson
2026-06-18 21:59:18 +02:00
parent da7d70825e
commit 98594c6cea
6 changed files with 161 additions and 51 deletions
+51
View File
@@ -13,6 +13,57 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
## Unreleased
## v1.1.5 — 2026-06-18
Patch release: SSH ControlMaster read-only-socket fix + pi `0.79.6``0.79.7`
(auto-resolved at build). The `pi-extensions` ref is auto-resolved to `main`
HEAD at build, so the `ssh-controlmaster` fix below lands automatically.
### Fixed
- **`pi --ssh <host>` no longer fails with "Read-only file system" when the
user's `~/.ssh/config` sets a per-host `ControlPath` under the read-only
`~/.ssh` mount** (e.g. the common CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`).
Root cause: SSH precedence means a user's per-host `ControlPath` always wins
over the baked `/etc/ssh/ssh_config.d` default, so the master socket tried to
bind under the RO `~/.ssh` and `ssh … pwd` exited 255 ("Could not resolve
remote pwd"). The `ssh-controlmaster` extension (pulled from `pi-extensions`
`main` via `PI_EXTENSIONS_REF`) now (a) resolves the remote pwd with a direct
connection (`-o ControlPath=none -o ControlMaster=no`), and (b) tests whether
the system `ControlPath` dir is actually writable — falling back to its own
`/tmp` master (whose command-line `-o ControlPath` overrides the user's path)
when it is not. OS-agnostic and independent of whether the user uses
ControlMaster, so the majority of configs (no ControlMaster at all) are
unaffected.
### Changed
- **`setup-lan-access.sh` now renders the writable SSH sidecar
(`~/.ssh-local/config`) on every host OS, not just VM-backed ones.**
Previously the whole script no-oped on native Linux, so a Linux host that
also bind-mounts `~/.ssh` read-only got no `ControlPath` redirect. The
`ControlPath` redirect + `Include ~/.ssh/config` (and `dssh`/`dscp` usability)
now work on Linux too; only the host-jump block (`Host host mac`), its key
generation, and the authorize hints remain gated on VM-backed detection
(`DEVBOX_LAN_ACCESS=auto`) or `=jump`.
### Bumped: pi 0.79.6 → 0.79.7
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.7)):
- **Automatic theme mode** — `/settings` can choose separate light and dark
themes and follow terminal color-scheme changes (`/` is now reserved in
theme names for this).
- **Self-only `pi update` by default** — bare `pi update` updates pi only;
`pi update --all` updates pi and packages together.
- **Extension API helpers** — `CONFIG_DIR_NAME` exported so extensions resolve
project config paths without hardcoding `.pi`; edit-diff helpers
(`generateDiffString`, `generateUnifiedPatch`, `EditDiffResult`) exported.
- **Warp inline images** via Kitty graphics capability detection.
- Fixes: RPC unknown-command errors now include the request id (clients no
longer hang); `/model` autocomplete matches provider/model regardless of
token order; tree navigator horizontally pans deep entries.
## v1.1.4 — 2026-06-17
Patch release: config and shell-quality fixes on a preserved volume. No pi
+1 -1
View File
@@ -97,7 +97,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
### SSH and networking
- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps).
- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps). A read-only `~/.ssh` carrying a per-host `ControlPath` (common CGNAT configs) is handled too — redirected to a writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
- A **LAN-access helper** that auto-configures ssh jump-via-host on VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container can reach the host's directly-attached LAN peers (`dssh <peer>` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER`).
## Versioning
+9
View File
@@ -130,6 +130,15 @@ RUN printf '%s\n' \
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
# so user config can override these defaults if desired.
#
# CAVEAT (and why it is handled elsewhere): a user per-host override that
# points ControlPath BACK under the read-only ~/.ssh (e.g. the common CGNAT
# idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the unwritable-socket
# failure — a system drop-in here can never override a user's per-host value.
# For `pi --ssh`, the ssh-controlmaster extension handles this by detecting an
# unwritable system ControlPath and falling back to its own /tmp master; for
# `ssh -F ~/.ssh-local/config` (dssh/dscp), setup-lan-access.sh redirects
# ControlPath into the writable ~/.ssh-local. See CHANGELOG "Unreleased".
#
# ControlPersist=10m means the master socket sticks around 10 min after
# the last session closes, so consecutive ssh calls in a workflow reuse
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
+20
View File
@@ -82,6 +82,9 @@ For Python REPLs and notebooks beyond the system interpreter, see the
- A LAN-access helper that auto-configures ssh jump-via-host on
VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container
can reach the host's directly-attached LAN peers.
- Read-only `~/.ssh` is handled transparently: a per-host `ControlPath`
under it (common CGNAT configs like `~/.ssh/cm/...`) is redirected to a
writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
## Quickstart
@@ -461,6 +464,23 @@ User-level overrides in `~/.ssh/config` win because Debian's
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
the `Host *` block.
### Per-host `ControlPath` on a read-only `~/.ssh`
`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that
points `ControlPath` back under it (e.g. the CGNAT idiom
`ControlPath ~/.ssh/cm/%r@%h:%p`) can't bind its master socket here — and a
system default can never override a user's per-host value. Two layers handle
this without editing the read-only config:
- **`pi --ssh <host>`** — the `ssh-controlmaster` extension detects an
unwritable system `ControlPath` and falls back to its own writable
`/tmp/pi-cm-<pid>.sock` master (its command-line `-o ControlPath` overrides
the user's path); the remote-`pwd` probe uses `-o ControlPath=none` so it
cannot fail on the read-only socket dir.
- **`ssh -F ~/.ssh-local/config` / `dssh` / `dscp`** — `setup-lan-access.sh`
redirects `ControlPath` into the writable `~/.ssh-local/cm` for every host
(the sidecar is rendered on all host OSes).
## tmux and 0-indexed sessions
The image installs `/etc/tmux.conf` with:
+10 -6
View File
@@ -12,12 +12,16 @@ set -euo pipefail
mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm
# ── LAN access: generic host-OS-agnostic reachability helper ────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default; this generates a
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
# SSH-jump-via-host block so the container can reach the host's
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
# jump block is omitted but the sidecar is still rendered. Controlled by
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
# script header.
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
fi
@@ -14,7 +14,9 @@
# The one thing reachable from a container on every OS is the host itself
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
# config that reaches the host and lets the user ProxyJump onward to LAN
# peers the host can reach. On native Linux we do nothing.
# peers the host can reach. On native Linux we render the same writable
# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no
# jump block, since LAN peers are reachable directly there.
#
# We ship the MECHANISM (a generic `host` jump alias + writable config),
# never the POLICY: the user's specific target hosts live in their own
@@ -30,7 +32,9 @@
#
# CONTROLS (env)
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
# auto → set up the host jump only on VM-backed hosts. The writable
# sidecar config (ControlPath redirect + Include) is always
# rendered, on every OS.
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
# off → do nothing.
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
@@ -84,42 +88,72 @@ is_vm_backed() {
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
}
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
# Native Linux host: LAN peers are reachable directly. Nothing to do.
exit 0
fi
# From here: MODE=jump, or MODE=auto on a VM-backed host.
command -v ssh-keygen >/dev/null 2>&1 || exit 0
# ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
# The ControlPath redirect in the generated config needs a writable directory
# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
# master socket lives under the writable ~/.ssh-local. We create it and render
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
# even on native Linux — where we set up no host jump but a read-only ~/.ssh
# would otherwise still break ControlMaster sockets.
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
# ── Jump key (generated once; preserved across restarts) ──────────────
# ── Decide whether to set up the host jump ────────────────────────────
# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump
# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker
# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN
# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but
# we still render the config for the ControlPath redirect + Include.
NEED_JUMP=0
if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then
NEED_JUMP=1
fi
# ── Jump key (only when a jump is needed; generated once, preserved) ──
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
# is generated only on the very first start (or if the volume is wiped). When
# we DO generate one it must be (re-)authorized on the host, so we flag it and
# print a copy-paste authorize line below.
KEY_JUST_GENERATED=0
if [ ! -f "$KEY" ]; then
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1
if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then
chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1
fi
fi
# ── Render the writable config ────────────────────────────────────────
USER_LINE=""
if [ -n "${HOST_SSH_USER:-}" ]; then
USER_LINE=" User ${HOST_SSH_USER}"
fi
# Optional host-owned named-peer jump overrides (portable: lives on the host,
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
# Jump-specific blocks (the host alias, host-owned peer overrides, and the
# optional RFC1918 catch-all) only make sense when a jump is set up; on native
# Linux they are all empty and only the ControlPath redirect + Include remain.
JUMP_BLOCK=""
LAN_CONF_BLOCK=""
if [ -r "$SSH_LAN_CONF" ]; then
LAN_CONF_BLOCK=$(cat <<'EOF'
AUTOJUMP_BLOCK=""
if [ "$NEED_JUMP" = "1" ]; then
USER_LINE=""
if [ -n "${HOST_SSH_USER:-}" ]; then
USER_LINE=" User ${HOST_SSH_USER}"
fi
JUMP_BLOCK=$(cat <<EOF
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
Host host mac
HostName ${HOST_ALIAS_HOSTNAME}
${USER_LINE}
IdentityFile ~/.ssh-local/devbox_jump_ed25519
IdentitiesOnly yes
ControlMaster auto
ControlPath ~/.ssh-local/cm/%r@%h:%p
ControlPersist 4h
ServerAliveInterval 30
EOF
)
# Optional host-owned named-peer jump overrides (portable: lives on the host,
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
if [ -r "$SSH_LAN_CONF" ]; then
LAN_CONF_BLOCK=$(cat <<'EOF'
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
# Scope reset to match-all so the Include applies to every target host.
@@ -127,14 +161,13 @@ Host *
Include ~/.config/devbox-shell/ssh-lan.conf
EOF
)
fi
fi
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
AUTOJUMP_BLOCK=""
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF'
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF'
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
@@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22
ProxyJump host
EOF
)
fi
fi
INCLUDE_BLOCK=""
@@ -176,17 +210,7 @@ Host *
UserKnownHostsFile ~/.ssh-local/known_hosts
StrictHostKeyChecking accept-new
ControlPath ~/.ssh-local/cm/%r@%h:%p
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
Host host mac
HostName ${HOST_ALIAS_HOSTNAME}
${USER_LINE}
IdentityFile ~/.ssh-local/devbox_jump_ed25519
IdentitiesOnly yes
ControlMaster auto
ControlPath ~/.ssh-local/cm/%r@%h:%p
ControlPersist 4h
ServerAliveInterval 30
${JUMP_BLOCK}
${LAN_CONF_BLOCK}
${AUTOJUMP_BLOCK}
${INCLUDE_BLOCK}
@@ -199,6 +223,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
# (b) fires only on first-ever start (or after the volume is reset) — so this
# is normally a one-time, one-line step per machine, with no file to locate.
if [ "$NEED_JUMP" = "1" ]; then
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
if [ -z "${HOST_SSH_USER:-}" ]; then
cat <<EOF
@@ -221,5 +246,6 @@ elif [ "$KEY_JUST_GENERATED" = "1" ]; then
repeat this on container updates — only if that volume is reset.
EOF
fi
fi
exit 0