fix(coding-agent): avoid SSH ControlMaster on Windows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, SSHConnectionTarget>();
|
||||
const pendingConnections = new Map<string, Promise<void>>();
|
||||
const hostInfoCache = new Map<string, SSHHostInfo>();
|
||||
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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<SSHHost
|
||||
return probeHostInfo(host);
|
||||
}
|
||||
|
||||
export async function buildRemoteCommand(host: SSHConnectionTarget, command: string): Promise<string[]> {
|
||||
export async function buildRemoteCommand(
|
||||
host: SSHConnectionTarget,
|
||||
command: string,
|
||||
options?: SSHArgsOptions,
|
||||
): Promise<string[]> {
|
||||
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<void>
|
||||
}
|
||||
|
||||
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<void>
|
||||
}
|
||||
|
||||
async function closeConnectionInternal(host: SSHConnectionTarget): Promise<void> {
|
||||
if (!supportsSshControlMaster()) return;
|
||||
const target = buildSshTarget(host.username, host.host);
|
||||
await runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user