feat(ssh-controlmaster): use ~/.ssh-local/config so pi --ssh can reach LAN peers

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 <config>` 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: <path>]
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.
This commit is contained in:
2026-06-20 22:40:57 +02:00
parent aaa7d906df
commit 8a47f2f3b4
3 changed files with 62 additions and 9 deletions
+18
View File
@@ -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 <lan-host>` from the route without a `ProxyJump host` entry, so `pi --ssh <lan-host>` from the
container now fails fast instead of stalling on the OS TCP timeout. 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`.** - **Password auth via `--ssh-ask-pass`.**
When the flag is set, `ctx.ui.input()` prompts for a password before 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 connecting. The password is passed to SSH via `SSH_ASKPASS`: a temp script at
+3 -1
View File
@@ -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 `<remoteCwd> (via SSH ControlMaster: <remote>)` 6. The system prompt is patched to tell the LLM it's operating on `<remoteCwd> (via SSH ControlMaster: <remote>)`
7. User `!` shell commands are also routed over SSH 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 <config>` into every ssh call when one is available, so `pi --ssh <peer>` 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: <path>]` 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. The status bar shows `⚡ own master` or `⚡ system master` so you can see which path was taken.
+41 -8
View File
@@ -28,7 +28,7 @@
*/ */
import { spawn } from "node:child_process"; 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 { access, writeFile, unlink } from "node:fs/promises";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path"; 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. // Wall-clock cap for ssh calls made during session_start.
const STARTUP_TIMEOUT_MS = 15000; const STARTUP_TIMEOUT_MS = 15000;
/**
* If a non-default SSH config should be used, resolve it to ["-F", <path>].
*
* 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 <lan-host>` 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 { function ownSocketPath(): string {
// Keep path short — macOS has a ~104-char Unix socket path limit // Keep path short — macOS has a ~104-char Unix socket path limit
return join(tmpdir(), `pi-cm-${process.pid}.sock`); return join(tmpdir(), `pi-cm-${process.pid}.sock`);
@@ -134,7 +165,7 @@ async function startControlMasterWithPassword(
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const child = spawn( const child = spawn(
"ssh", "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", stdio: "ignore",
env: { env: {
@@ -203,7 +234,7 @@ async function readSshConfig(remote: string): Promise<CmConfig> {
// Strip any user@ prefix for -G (ssh -G takes a hostname or alias, not user@host) // Strip any user@ prefix for -G (ssh -G takes a hostname or alias, not user@host)
const host = remote.includes("@") ? remote.split("@")[1] : remote; const host = remote.includes("@") ? remote.split("@")[1] : remote;
try { try {
const output = await run(["ssh", "-G", host]); const output = await run(["ssh", ...SSH_CONFIG_OPTS, "-G", host]);
const get = (key: string): string => { const get = (key: string): string => {
const m = output.match(new RegExp(`^${key}\\s+(.+)$`, "im")); const m = output.match(new RegExp(`^${key}\\s+(.+)$`, "im"));
return m ? m[1].trim() : ""; return m ? m[1].trim() : "";
@@ -273,7 +304,7 @@ function startControlMaster(remote: string, socketPath: string): Promise<void> {
// prompt (which would hang invisibly behind pi's TUI). // prompt (which would hang invisibly behind pi's TUI).
const child = spawn( const child = spawn(
"ssh", "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" }, { stdio: "ignore" },
); );
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -293,7 +324,7 @@ function stopControlMaster(remote: string, socketPath: string): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn( const child = spawn(
"ssh", "ssh",
["-O", "exit", "-o", `ControlPath=${socketPath}`, remote], [...SSH_CONFIG_OPTS, "-O", "exit", "-o", `ControlPath=${socketPath}`, remote],
{ stdio: "ignore" }, { stdio: "ignore" },
); );
child.on("close", () => resolve()); // best-effort; ignore errors child.on("close", () => resolve()); // best-effort; ignore errors
@@ -311,7 +342,7 @@ function sshExec(remote: string, socketPath: string, command: string): Promise<B
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn( const child = spawn(
"ssh", "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"] }, { stdio: ["ignore", "pipe", "pipe"] },
); );
const out: Buffer[] = []; const out: Buffer[] = [];
@@ -380,7 +411,7 @@ function createRemoteBashOps(
const cmd = `cd ${JSON.stringify(r(cwd))} && ${command}`; const cmd = `cd ${JSON.stringify(r(cwd))} && ${command}`;
const child = spawn( const child = spawn(
"ssh", "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"] }, { stdio: ["ignore", "pipe", "pipe"] },
); );
let timedOut = false; 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 // 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. // waits silently on a /dev/tty prompt; it is omitted under --ssh-ask-pass.
const probeOpts = [ const probeOpts = [
...SSH_CONFIG_OPTS,
"-o", "ControlPath=none", "-o", "ControlPath=none",
"-o", "ControlMaster=no", "-o", "ControlMaster=no",
...CONNECT_OPTS, ...CONNECT_OPTS,
@@ -552,8 +584,9 @@ export default function (pi: ExtensionAPI) {
state = { remote, remoteCwd, socketPath, ownsmaster }; state = { remote, remoteCwd, socketPath, ownsmaster };
const tag = ownsmaster ? "⚡ own master" : "⚡ system master"; 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.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 () => { pi.on("session_shutdown", async () => {