diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fff5b55bf..ac4743da3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed subagents launched in the same parallel batch not seeing each other in their initial `# IRC Peers` system-prompt block by pre-registering the agent in the global `AgentRegistry` before `rebuildSystemPrompt` runs and attaching the live session afterwards +- Fixed the SSH tool on native Windows by avoiding OpenSSH ControlMaster multiplexing, which Win32-OpenSSH does not support and reports as `getsockname failed` ([#154](https://github.com/can1357/oh-my-pi/issues/154)). ## [14.9.3] - 2026-05-10 ### Breaking Changes diff --git a/packages/coding-agent/src/ssh/connection-manager.ts b/packages/coding-agent/src/ssh/connection-manager.ts index ded5f0c78..4cf0ad211 100644 --- a/packages/coding-agent/src/ssh/connection-manager.ts +++ b/packages/coding-agent/src/ssh/connection-manager.ts @@ -15,6 +15,11 @@ export interface SSHConnectionTarget { export type SSHHostOs = "windows" | "linux" | "macos" | "unknown"; export type SSHHostShell = "cmd" | "powershell" | "bash" | "zsh" | "sh" | "unknown"; +export type SshPlatform = typeof process.platform; + +export function supportsSshControlMaster(platform: SshPlatform = process.platform): boolean { + return platform !== "win32"; +} export interface SSHHostInfo { version: number; @@ -33,6 +38,10 @@ const activeHosts = new Map(); const pendingConnections = new Map>(); const hostInfoCache = new Map(); +interface SSHArgsOptions { + platform?: SshPlatform; +} + function ensureControlDir() { fs.mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 }); try { @@ -66,20 +75,14 @@ async function validateKeyPermissions(keyPath?: string): Promise { } } -function buildCommonArgs(host: SSHConnectionTarget): string[] { - const args = [ - "-n", - "-o", - "ControlMaster=auto", - "-o", - `ControlPath=${CONTROL_PATH}`, - "-o", - "ControlPersist=3600", - "-o", - "BatchMode=yes", - "-o", - "StrictHostKeyChecking=accept-new", - ]; +function buildCommonArgs(host: SSHConnectionTarget, options?: SSHArgsOptions): string[] { + const args = ["-n"]; + + if (supportsSshControlMaster(options?.platform)) { + args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${CONTROL_PATH}`, "-o", "ControlPersist=3600"); + } + + args.push("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new"); if (host.port) { args.push("-p", String(host.port)); @@ -357,9 +360,13 @@ export async function ensureHostInfo(host: SSHConnectionTarget): Promise { +export async function buildRemoteCommand( + host: SSHConnectionTarget, + command: string, + options?: SSHArgsOptions, +): Promise { await validateKeyPermissions(host.keyPath); - return [...buildCommonArgs(host), buildSshTarget(host.username, host.host), command]; + return [...buildCommonArgs(host, options), buildSshTarget(host.username, host.host), command]; } let registered = false; @@ -385,6 +392,14 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise } const target = buildSshTarget(host.username, host.host); + if (!supportsSshControlMaster()) { + activeHosts.set(key, host); + if (!hostInfoCache.has(key) && !(await loadHostInfoFromDisk(host))) { + await probeHostInfo(host); + } + return; + } + const check = await runSshSync(["-O", "check", ...buildCommonArgs(host), target]); if (check.exitCode === 0) { activeHosts.set(key, host); @@ -415,6 +430,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise } async function closeConnectionInternal(host: SSHConnectionTarget): Promise { + if (!supportsSshControlMaster()) return; const target = buildSshTarget(host.username, host.host); await runSshSync(["-O", "exit", ...buildCommonArgs(host), target]); } diff --git a/packages/coding-agent/src/ssh/sshfs-mount.ts b/packages/coding-agent/src/ssh/sshfs-mount.ts index 402c287e7..efc1e2351 100644 --- a/packages/coding-agent/src/ssh/sshfs-mount.ts +++ b/packages/coding-agent/src/ssh/sshfs-mount.ts @@ -2,7 +2,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { $which, getRemoteDir, postmortem } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; -import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager"; +import { + getControlDir, + getControlPathTemplate, + type SSHConnectionTarget, + supportsSshControlMaster, +} from "./connection-manager"; import { buildSshTarget, sanitizeHostName } from "./utils"; const REMOTE_DIR = getRemoteDir(); @@ -40,14 +45,12 @@ function buildSshfsArgs(host: SSHConnectionTarget): string[] { "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", - "-o", - "ControlMaster=auto", - "-o", - `ControlPath=${CONTROL_PATH}`, - "-o", - "ControlPersist=3600", ]; + if (supportsSshControlMaster()) { + args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${CONTROL_PATH}`, "-o", "ControlPersist=3600"); + } + if (host.port) { args.push("-p", String(host.port)); } diff --git a/packages/coding-agent/test/ssh/connection-manager.test.ts b/packages/coding-agent/test/ssh/connection-manager.test.ts index 655e602c2..8ec611252 100644 --- a/packages/coding-agent/test/ssh/connection-manager.test.ts +++ b/packages/coding-agent/test/ssh/connection-manager.test.ts @@ -1,14 +1,15 @@ import { describe, expect, it } from "bun:test"; -import { buildRemoteCommand } from "../../src/ssh/connection-manager"; +import { buildRemoteCommand, supportsSshControlMaster } from "../../src/ssh/connection-manager"; describe("buildRemoteCommand", () => { - it("includes -n to bind stdin to /dev/null for mux channel opens", async () => { + it("includes -n and OpenSSH ControlMaster options on Unix-like platforms", async () => { const args = await buildRemoteCommand( { name: "host", host: "192.168.3.146", }, "ls -la", + { platform: "linux" }, ); expect(args[0]).toBe("-n"); @@ -16,4 +17,34 @@ describe("buildRemoteCommand", () => { expect(args.at(-2)).toBe("192.168.3.146"); expect(args.at(-1)).toBe("ls -la"); }); + + it("omits OpenSSH ControlMaster options on Windows", async () => { + const args = await buildRemoteCommand( + { + name: "host", + host: "192.168.3.146", + }, + "ls -la", + { platform: "win32" }, + ); + + expect(args[0]).toBe("-n"); + expect(args).not.toContain("ControlMaster=auto"); + expect(args.some(arg => arg.startsWith("ControlPath="))).toBe(false); + expect(args).not.toContain("ControlPersist=3600"); + expect(args).toContain("BatchMode=yes"); + expect(args.at(-2)).toBe("192.168.3.146"); + expect(args.at(-1)).toBe("ls -la"); + }); +}); + +describe("supportsSshControlMaster", () => { + it("disables OpenSSH connection multiplexing on native Windows", () => { + expect(supportsSshControlMaster("win32")).toBe(false); + }); + + it("keeps OpenSSH connection multiplexing on Unix-like platforms", () => { + expect(supportsSshControlMaster("linux")).toBe(true); + expect(supportsSshControlMaster("darwin")).toBe(true); + }); });