From 8a47f2f3b48e04d980e9da6329883dcc26aada5c Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Sat, 20 Jun 2026 22:40:57 +0200 Subject: [PATCH] feat(ssh-controlmaster): use ~/.ssh-local/config so pi --ssh can reach LAN peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dssh reaches host-LAN peers from inside the devbox container because it runs `ssh -F ~/.ssh-local/config` (which Includes the host-owned, bind-mounted ssh-lan.conf carrying `ProxyJump host` entries). pi --ssh shelled out to plain `ssh`/`ssh -G` against the default ~/.ssh/config, which has no jump, so it could not reach peers the host can. Thread `-F ` through every ssh call (ssh -G, pwd probe, master start for both key and password paths, sshExec, bash exec, ssh -O exit), resolved once at load by resolveSshConfigOpts(): PI_SSH_CONFIG (leading ~ expanded, honored even if missing) else ~/.ssh-local/config if present else [] (no -F) No hostnames are baked into the image — the LAN list lives only in the host-owned, read-only-mounted ~/.config/devbox-shell/ssh-lan.conf. On the host (native pi) ~/.ssh-local/config doesn't exist, so -F is omitted and behavior is unchanged. Command-line -o options still win over -F, so own-master /tmp socket and ControlMaster decisions are unaffected. Status/notify shows [config: ] when a non-default config is used. Verified from the container: the patched probe reaches an enrolled peer (pve -> /root) where plain ssh times out. Reaching a new peer (e.g. alpserv-2) is now a one-line host-side edit to ssh-lan.conf. Docs: README + AGENTS.md updated. --- AGENTS.md | 18 ++++++++++++ README.md | 4 ++- extensions/ssh-controlmaster.ts | 49 +++++++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cb1a6c6..3efbd31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,24 @@ on a remote machine via SSH when `--ssh user@host` is passed. route without a `ProxyJump host` entry, so `pi --ssh ` from the container now fails fast instead of stalling on the OS TCP timeout. +- **LAN reachability via `-F` config detection (`SSH_CONFIG_OPTS`).** + Every ssh call (`ssh -G`, pwd probe, master start, `sshExec`, bash exec, + `ssh -O exit`) is prefixed with `...SSH_CONFIG_OPTS`, resolved once at module + load by `resolveSshConfigOpts()`: `PI_SSH_CONFIG` if set (leading `~` + expanded, honored even if missing), else `~/.ssh-local/config` if it exists, + else `[]`. This closes the gap where `dssh` (`ssh -F ~/.ssh-local/config`) + could reach LAN peers via `ProxyJump host` but `pi --ssh` — which shelled out + to plain `ssh`/`ssh -G` against the default `~/.ssh/config` — could not. The + sidecar config is regenerated by the devbox's `setup-lan-access.sh` on every + container start and includes the host-owned, read-only-mounted + `~/.config/devbox-shell/ssh-lan.conf` (the `ProxyJump host` list). **No + hostnames are baked into the image**; on the host `~/.ssh-local/config` is + absent so `-F` is omitted and behavior is unchanged. Command-line `-o` + options still win over the `-F` config, so the own-master `/tmp` socket path + and `ControlMaster=yes/no` decisions are unaffected. Enrolling a new peer is a + one-line host-side edit to `ssh-lan.conf` (the peer must already be a `Host` + block in the host's `~/.ssh/config`, which supplies HostName/User/key). + - **Password auth via `--ssh-ask-pass`.** When the flag is set, `ctx.ui.input()` prompts for a password before connecting. The password is passed to SSH via `SSH_ASKPASS`: a temp script at diff --git a/README.md b/README.md index dbb8010..4f5364a 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ pi -e ~/src/src_local/pi-extensions/extensions/ssh-controlmaster.ts --ssh user@h 6. The system prompt is patched to tell the LLM it's operating on ` (via SSH ControlMaster: )` 7. User `!` shell commands are also routed over SSH -**Hang-proofing:** every SSH invocation carries `ConnectTimeout=8` + `ServerAlive*` keepalives, and the startup probe / master start are additionally bounded by a 15 s wall-clock timeout. Key-auth calls also set `BatchMode=yes` so ssh can never wait silently on a `/dev/tty` password/passphrase or host-key prompt behind pi's TUI (`--ssh-ask-pass` omits `BatchMode` so the `SSH_ASKPASS` path still works). If the host can't be reached — e.g. a LAN target with **no route from inside a container** (`pi --ssh` needs a `ProxyJump` to reach LAN hosts from a devbox) — the probe fails fast with an `✗ unreachable` status and an error toast instead of hanging startup and silently swallowing your prompts. +**Hang-proofing:** every SSH invocation carries `ConnectTimeout=8` + `ServerAlive*` keepalives, and the startup probe / master start are additionally bounded by a 15 s wall-clock timeout. Key-auth calls also set `BatchMode=yes` so ssh can never wait silently on a `/dev/tty` password/passphrase or host-key prompt behind pi's TUI (`--ssh-ask-pass` omits `BatchMode` so the `SSH_ASKPASS` path still works). If the host can't be reached — e.g. a LAN target with **no route from inside a container** — the probe fails fast with an `✗ unreachable` status and an error toast instead of hanging startup and silently swallowing your prompts. + +**LAN reachability from inside a container (`-F` config detection):** the extension threads an `-F ` into every ssh call when one is available, so `pi --ssh ` can reach hosts that require a `ProxyJump` exactly like the `dssh` alias does. Resolution order: `PI_SSH_CONFIG=/path/to/config` if set (leading `~` expanded), else `~/.ssh-local/config` if it exists (the pi-devbox's `setup-lan-access.sh` regenerates it on every container start), else none. **No hostnames are baked into the image** — the LAN-jump list lives only in the host-owned, read-only-mounted `~/.config/devbox-shell/ssh-lan.conf`; on the host (native pi) `~/.ssh-local/config` doesn't exist so this is a no-op. The status/notify shows `[config: ]` when a non-default config is in use. To reach a new LAN peer from the container, add it to the host's `ssh-lan.conf` `ProxyJump host` line (it must already be a `Host` block in the host's `~/.ssh/config`). The status bar shows `⚡ own master` or `⚡ system master` so you can see which path was taken. diff --git a/extensions/ssh-controlmaster.ts b/extensions/ssh-controlmaster.ts index 169efcd..116b01c 100644 --- a/extensions/ssh-controlmaster.ts +++ b/extensions/ssh-controlmaster.ts @@ -28,7 +28,7 @@ */ import { spawn } from "node:child_process"; -import { constants as fsConstants } from "node:fs"; +import { constants as fsConstants, existsSync } from "node:fs"; import { access, writeFile, unlink } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -71,6 +71,37 @@ const BATCH_OPTS = ["-o", "BatchMode=yes"]; // Wall-clock cap for ssh calls made during session_start. const STARTUP_TIMEOUT_MS = 15000; +/** + * If a non-default SSH config should be used, resolve it to ["-F", ]. + * + * Why: inside the pi-devbox container, `setup-lan-access.sh` regenerates + * ~/.ssh-local/config on every start. That config carries the LAN-jump + * (`ProxyJump host`) overrides the host contributes via the bind-mounted, + * read-only ssh-lan.conf, plus a writable ControlPath. `dssh` reaches LAN peers + * because it runs `ssh -F ~/.ssh-local/config`; the default `ssh`/`ssh -G` does + * NOT, which is exactly why `pi --ssh ` couldn't reach a peer the host + * can. We thread the same `-F` through every ssh call this extension makes. + * + * No hostnames are baked into the image — the LAN list lives only in the + * host-owned, read-only-mounted ssh-lan.conf. On the host (native pi) + * ~/.ssh-local/config does not exist, so this is a no-op and default ssh + * behavior is unchanged. + * + * Override: PI_SSH_CONFIG=/path/to/config (honored even when auto-detect would + * find nothing; a leading ~ is expanded). PI_SSH_CONFIG= (empty) = unset. + */ +function resolveSshConfigOpts(): { opts: string[]; path: string | null } { + const expand = (p: string) => p.replace(/^~(?=\/|$)/, homedir()); + const raw = process.env.PI_SSH_CONFIG?.trim(); + if (raw) { + const p = expand(raw); + return { opts: ["-F", p], path: p }; + } + const sidecar = join(homedir(), ".ssh-local", "config"); + return existsSync(sidecar) ? { opts: ["-F", sidecar], path: sidecar } : { opts: [], path: null }; +} +const { opts: SSH_CONFIG_OPTS, path: SSH_CONFIG_PATH } = resolveSshConfigOpts(); + function ownSocketPath(): string { // Keep path short — macOS has a ~104-char Unix socket path limit return join(tmpdir(), `pi-cm-${process.pid}.sock`); @@ -134,7 +165,7 @@ async function startControlMasterWithPassword( await new Promise((resolve, reject) => { const child = spawn( "ssh", - ["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, remote], + [...SSH_CONFIG_OPTS, "-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, remote], { stdio: "ignore", env: { @@ -203,7 +234,7 @@ async function readSshConfig(remote: string): Promise { // Strip any user@ prefix for -G (ssh -G takes a hostname or alias, not user@host) const host = remote.includes("@") ? remote.split("@")[1] : remote; try { - const output = await run(["ssh", "-G", host]); + const output = await run(["ssh", ...SSH_CONFIG_OPTS, "-G", host]); const get = (key: string): string => { const m = output.match(new RegExp(`^${key}\\s+(.+)$`, "im")); return m ? m[1].trim() : ""; @@ -273,7 +304,7 @@ function startControlMaster(remote: string, socketPath: string): Promise { // prompt (which would hang invisibly behind pi's TUI). const child = spawn( "ssh", - ["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, ...BATCH_OPTS, remote], + [...SSH_CONFIG_OPTS, "-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, ...BATCH_OPTS, remote], { stdio: "ignore" }, ); const timer = setTimeout(() => { @@ -293,7 +324,7 @@ function stopControlMaster(remote: string, socketPath: string): Promise { return new Promise((resolve) => { const child = spawn( "ssh", - ["-O", "exit", "-o", `ControlPath=${socketPath}`, remote], + [...SSH_CONFIG_OPTS, "-O", "exit", "-o", `ControlPath=${socketPath}`, remote], { stdio: "ignore" }, ); child.on("close", () => resolve()); // best-effort; ignore errors @@ -311,7 +342,7 @@ function sshExec(remote: string, socketPath: string, command: string): Promise { const child = spawn( "ssh", - ["-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, command], + [...SSH_CONFIG_OPTS, "-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, command], { stdio: ["ignore", "pipe", "pipe"] }, ); const out: Buffer[] = []; @@ -380,7 +411,7 @@ function createRemoteBashOps( const cmd = `cd ${JSON.stringify(r(cwd))} && ${command}`; const child = spawn( "ssh", - ["-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, cmd], + [...SSH_CONFIG_OPTS, "-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, cmd], { stdio: ["ignore", "pipe", "pipe"] }, ); let timedOut = false; @@ -500,6 +531,7 @@ export default function (pi: ExtensionAPI) { // 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 = [ + ...SSH_CONFIG_OPTS, "-o", "ControlPath=none", "-o", "ControlMaster=no", ...CONNECT_OPTS, @@ -552,8 +584,9 @@ export default function (pi: ExtensionAPI) { state = { remote, remoteCwd, socketPath, ownsmaster }; const tag = ownsmaster ? "⚡ own master" : "⚡ system master"; + const cfgNote = SSH_CONFIG_PATH ? ` [config: ${SSH_CONFIG_PATH}]` : ""; ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH ${tag} ${remote}:${remoteCwd}`)); - ctx.ui.notify(`SSH ready (${ownsmaster ? "own" : "system"} master) — ${remote}:${remoteCwd}`, "success"); + ctx.ui.notify(`SSH ready (${ownsmaster ? "own" : "system"} master) — ${remote}:${remoteCwd}${cfgNote}`, "success"); }); pi.on("session_shutdown", async () => {