Files
oh-my-pi/packages/coding-agent/src/ssh/connection-manager.ts
T
can1357 fcd7ff4f84 refactor(dirs): centralized directory path utilities into @oh-my-pi/pi-utils/dirs module
- Extracted directory path utilities from multiple packages into a centralized '@oh-my-pi/pi-utils/dirs' module.
- Moved 30+ path helper functions (getAgentDir, getConfigRootDir, getPluginsDir, getMCPConfigPath, etc.) from scattered locations into a single shared utility module.
- Consolidated APP_NAME, CONFIG_DIR_NAME, and VERSION constants into the centralized dirs module for reuse across packages.
- Updated 70+ import statements across packages/ai, packages/coding-agent, packages/stats, and packages/tui to use the new centralized module.
- Removed local path construction logic and replaced with utility function calls for improved maintainability and consistency.
- Deleted packages/coding-agent/src/extensibility/plugins/paths.ts as its functions were moved to the centralized dirs module.
2026-02-13 15:06:05 +01:00

445 lines
14 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getRemoteHostDir, getSshControlDir } from "@oh-my-pi/pi-utils/dirs";
import { $ } from "bun";
export interface SSHConnectionTarget {
name: string;
host: string;
username?: string;
port?: number;
keyPath?: string;
compat?: boolean;
}
export type SSHHostOs = "windows" | "linux" | "macos" | "unknown";
export type SSHHostShell = "cmd" | "powershell" | "bash" | "zsh" | "sh" | "unknown";
export interface SSHHostInfo {
version: number;
os: SSHHostOs;
shell: SSHHostShell;
compatShell?: "bash" | "sh";
compatEnabled: boolean;
}
const CONTROL_DIR = getSshControlDir();
const CONTROL_PATH = path.join(CONTROL_DIR, "%h.sock");
const HOST_INFO_DIR = getRemoteHostDir();
const HOST_INFO_VERSION = 2;
const activeHosts = new Map<string, SSHConnectionTarget>();
const pendingConnections = new Map<string, Promise<void>>();
const hostInfoCache = new Map<string, SSHHostInfo>();
function ensureControlDir() {
fs.mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 });
try {
fs.chmodSync(CONTROL_DIR, 0o700);
} catch (err) {
logger.debug("SSH control dir chmod failed", { path: CONTROL_DIR, error: String(err) });
}
}
function sanitizeHostName(name: string): string {
const sanitized = name.replace(/[^a-zA-Z0-9._-]+/g, "_");
return sanitized.length > 0 ? sanitized : "host";
}
function getHostInfoPath(name: string): string {
return path.join(HOST_INFO_DIR, `${sanitizeHostName(name)}.json`);
}
async function validateKeyPermissions(keyPath?: string): Promise<void> {
if (!keyPath) return;
let stats: fs.Stats;
try {
stats = await fs.promises.stat(keyPath);
} catch (err) {
if (isEnoent(err)) {
throw new Error(`SSH key not found: ${keyPath}`);
}
throw err;
}
if (!stats.isFile()) {
throw new Error(`SSH key is not a file: ${keyPath}`);
}
const mode = stats.mode & 0o777;
if ((mode & 0o077) !== 0) {
throw new Error(`SSH key permissions must be 600 or stricter: ${keyPath}`);
}
}
function buildSshTarget(host: SSHConnectionTarget): string {
return host.username ? `${host.username}@${host.host}` : host.host;
}
function buildCommonArgs(host: SSHConnectionTarget): string[] {
const args = [
"-o",
"ControlMaster=auto",
"-o",
`ControlPath=${CONTROL_PATH}`,
"-o",
"ControlPersist=3600",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
];
if (host.port) {
args.push("-p", String(host.port));
}
if (host.keyPath) {
args.push("-i", host.keyPath);
}
return args;
}
async function runSshSync(args: string[]): Promise<{ exitCode: number | null; stderr: string }> {
const result = await $`ssh ${args}`.nothrow();
return { exitCode: result.exitCode, stderr: result.stderr.toString().trim() };
}
async function runSshCaptureSync(args: string[]): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
const result = await $`ssh ${args}`.nothrow();
return {
exitCode: result.exitCode,
stdout: result.stdout.toString().trim(),
stderr: result.stderr.toString().trim(),
};
}
function ensureSshBinary(): void {
if (!Bun.which("ssh")) {
throw new Error("ssh binary not found on PATH");
}
}
function parseOs(value: unknown): SSHHostOs | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
switch (normalized) {
case "windows":
return "windows";
case "linux":
return "linux";
case "macos":
case "darwin":
return "macos";
case "unknown":
return "unknown";
default:
return null;
}
}
function parseShell(value: unknown): SSHHostShell | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (!normalized) return "unknown";
if (normalized.includes("bash")) return "bash";
if (normalized.includes("zsh")) return "zsh";
if (normalized.includes("pwsh") || normalized.includes("powershell")) return "powershell";
if (normalized.includes("cmd.exe") || normalized === "cmd") return "cmd";
if (normalized.endsWith("sh") || normalized.includes("/sh")) return "sh";
return "unknown";
}
function parseCompatShell(value: unknown): "bash" | "sh" | undefined {
if (value === "bash" || value === "sh") return value;
return undefined;
}
function applyCompatOverride(host: SSHConnectionTarget, info: SSHHostInfo): SSHHostInfo {
const compatShell =
info.compatShell ??
(info.os === "windows" && info.shell === "bash"
? "bash"
: info.os === "windows" && info.shell === "sh"
? "sh"
: undefined);
const compatEnabled = host.compat === false ? false : info.os === "windows" && compatShell !== undefined;
if (host.compat === true && !compatShell) {
logger.warn("SSH compat requested but no compatible shell detected", {
host: host.name,
shell: info.shell,
});
}
return { ...info, version: info.version ?? 0, compatShell, compatEnabled };
}
function parseHostInfo(value: unknown): SSHHostInfo | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const os = parseOs(record.os) ?? "unknown";
const shell = parseShell(record.shell) ?? "unknown";
const compatShell = parseCompatShell(record.compatShell);
const compatEnabled = typeof record.compatEnabled === "boolean" ? record.compatEnabled : false;
const version = typeof record.version === "number" ? record.version : 0;
return {
version,
os,
shell,
compatShell,
compatEnabled,
};
}
function shouldRefreshHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): boolean {
if (info.version !== HOST_INFO_VERSION) return true;
if (info.os === "unknown") return true;
if (info.os !== "windows" && info.compatEnabled) return true;
if (info.os === "windows" && info.compatEnabled && !info.compatShell) return true;
if (info.os === "windows" && info.compatShell === "bash" && info.shell === "unknown") return true;
if (host.compat === true && info.os === "windows" && !info.compatShell) return true;
return false;
}
async function loadHostInfoFromDisk(host: SSHConnectionTarget): Promise<SSHHostInfo | undefined> {
const path = getHostInfoPath(host.name);
try {
const raw = await fs.promises.readFile(path, "utf-8");
const parsed = parseHostInfo(JSON.parse(raw));
if (!parsed) return undefined;
const resolved = applyCompatOverride(host, parsed);
hostInfoCache.set(host.name, resolved);
return resolved;
} catch (err) {
if (isEnoent(err)) return undefined;
logger.warn("Failed to load SSH host info", { host: host.name, error: String(err) });
return undefined;
}
}
async function loadHostInfoFromDiskByName(hostName: string): Promise<SSHHostInfo | undefined> {
const path = getHostInfoPath(hostName);
try {
const raw = await fs.promises.readFile(path, "utf-8");
const parsed = parseHostInfo(JSON.parse(raw));
if (!parsed) return undefined;
return parsed;
} catch (err) {
if (isEnoent(err)) return undefined;
logger.warn("Failed to load SSH host info", { host: hostName, error: String(err) });
return undefined;
}
}
async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Promise<void> {
try {
const path = getHostInfoPath(host.name);
const payload = { ...info, version: HOST_INFO_VERSION };
hostInfoCache.set(host.name, payload);
await Bun.write(path, JSON.stringify(payload, null, 2), { createPath: true });
} catch (err) {
logger.warn("Failed to persist SSH host info", { host: host.name, error: String(err) });
}
}
async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
const command = 'echo "$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "%OS%|%COMSPEC%|"';
const result = await runSshCaptureSync(await buildRemoteCommand(host, command));
if (result.exitCode !== 0 && !result.stdout) {
logger.debug("SSH host probe failed", { host: host.name, error: result.stderr });
const fallback: SSHHostInfo = {
version: HOST_INFO_VERSION,
os: "unknown",
shell: "unknown",
compatShell: undefined,
compatEnabled: false,
};
hostInfoCache.set(host.name, fallback);
return fallback;
}
const output = (result.stdout || result.stderr).split("\n")[0]?.trim() ?? "";
const [rawOs = "", rawShell = "", rawBash = ""] = output.split("|");
const ostype = rawOs.trim();
const shellRaw = rawShell.trim();
const bashVersion = rawBash.trim();
const outputLower = output.toLowerCase();
const osLower = ostype.toLowerCase();
const shellLower = shellRaw.toLowerCase();
const unexpandedPosixVars =
output.includes("$OSTYPE") || output.includes("$SHELL") || output.includes("$BASH_VERSION");
const windowsDetected =
osLower.includes("windows") ||
osLower.includes("msys") ||
osLower.includes("cygwin") ||
osLower.includes("mingw") ||
outputLower.includes("windows_nt") ||
outputLower.includes("comspec") ||
shellLower.includes("cmd") ||
shellLower.includes("powershell") ||
unexpandedPosixVars ||
output.includes("%OS%");
let os: SSHHostOs = "unknown";
if (windowsDetected) {
os = "windows";
} else if (osLower.includes("darwin")) {
os = "macos";
} else if (osLower.includes("linux") || osLower.includes("gnu")) {
os = "linux";
}
let shell: SSHHostShell = "unknown";
if (shellLower.includes("bash")) {
shell = "bash";
} else if (shellLower.includes("zsh")) {
shell = "zsh";
} else if (shellLower.includes("pwsh") || shellLower.includes("powershell")) {
shell = "powershell";
} else if (shellLower.includes("cmd.exe") || shellLower === "cmd") {
shell = "cmd";
} else if (shellLower.endsWith("sh") || shellLower.includes("/sh")) {
shell = "sh";
} else if (os === "windows" && !shellLower) {
shell = "cmd";
}
const hasBash = !unexpandedPosixVars && (Boolean(bashVersion) || shell === "bash");
let compatShell: SSHHostInfo["compatShell"];
if (os === "windows" && host.compat !== false) {
const bashProbe = await runSshCaptureSync(await buildRemoteCommand(host, 'bash -lc "echo PI_BASH_OK"'));
if (bashProbe.exitCode === 0 && bashProbe.stdout.includes("PI_BASH_OK")) {
compatShell = "bash";
} else {
const shProbe = await runSshCaptureSync(await buildRemoteCommand(host, 'sh -lc "echo PI_SH_OK"'));
if (shProbe.exitCode === 0 && shProbe.stdout.includes("PI_SH_OK")) {
compatShell = "sh";
}
}
} else if (os === "windows" && hasBash) {
compatShell = "bash";
} else if (os === "windows" && shell === "sh") {
compatShell = "sh";
}
const compatEnabled = host.compat === false ? false : os === "windows" && compatShell !== undefined;
const info: SSHHostInfo = applyCompatOverride(host, {
version: HOST_INFO_VERSION,
os,
shell,
compatShell,
compatEnabled,
});
hostInfoCache.set(host.name, info);
await persistHostInfo(host, info);
return info;
}
export async function getHostInfo(hostName: string): Promise<SSHHostInfo | undefined> {
const cached = hostInfoCache.get(hostName);
if (cached) return cached;
return loadHostInfoFromDiskByName(hostName);
}
export async function getHostInfoForHost(host: SSHConnectionTarget): Promise<SSHHostInfo | undefined> {
const cached = hostInfoCache.get(host.name);
if (cached) {
const resolved = applyCompatOverride(host, cached);
if (resolved !== cached) hostInfoCache.set(host.name, resolved);
return resolved;
}
return await loadHostInfoFromDisk(host);
}
export async function ensureHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
const cached = hostInfoCache.get(host.name);
if (cached) {
const resolved = applyCompatOverride(host, cached);
hostInfoCache.set(host.name, resolved);
if (!shouldRefreshHostInfo(host, resolved)) return resolved;
}
const fromDisk = await loadHostInfoFromDisk(host);
if (fromDisk && !shouldRefreshHostInfo(host, fromDisk)) return fromDisk;
await ensureConnection(host);
const current = hostInfoCache.get(host.name);
if (current && !shouldRefreshHostInfo(host, current)) return current;
return probeHostInfo(host);
}
export async function buildRemoteCommand(host: SSHConnectionTarget, command: string): Promise<string[]> {
await validateKeyPermissions(host.keyPath);
return [...buildCommonArgs(host), buildSshTarget(host), command];
}
export async function ensureConnection(host: SSHConnectionTarget): Promise<void> {
const key = host.name;
const pending = pendingConnections.get(key);
if (pending) {
await pending;
return;
}
const promise = (async () => {
ensureSshBinary();
ensureControlDir();
await validateKeyPermissions(host.keyPath);
const target = buildSshTarget(host);
const check = await runSshSync(["-O", "check", ...buildCommonArgs(host), target]);
if (check.exitCode === 0) {
activeHosts.set(key, host);
if (!hostInfoCache.has(key) && !(await loadHostInfoFromDisk(host))) {
await probeHostInfo(host);
}
return;
}
const start = await runSshSync(["-M", "-N", "-f", ...buildCommonArgs(host), target]);
if (start.exitCode !== 0) {
const detail = start.stderr ? `: ${start.stderr}` : "";
throw new Error(`Failed to start SSH master for ${target}${detail}`);
}
activeHosts.set(key, host);
if (!hostInfoCache.has(key) && !(await loadHostInfoFromDisk(host))) {
await probeHostInfo(host);
}
})();
pendingConnections.set(key, promise);
try {
await promise;
} finally {
pendingConnections.delete(key);
}
}
async function closeConnectionInternal(host: SSHConnectionTarget): Promise<void> {
const target = buildSshTarget(host);
await runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
}
export async function closeConnection(hostName: string): Promise<void> {
const host = activeHosts.get(hostName);
if (!host) {
await closeConnectionInternal({ name: hostName, host: hostName });
return;
}
await closeConnectionInternal(host);
activeHosts.delete(hostName);
}
export async function closeAllConnections(): Promise<void> {
for (const [name, host] of Array.from(activeHosts.entries())) {
await closeConnectionInternal(host);
activeHosts.delete(name);
}
}
export function getControlPathTemplate(): string {
return CONTROL_PATH;
}
export function getControlDir(): string {
return CONTROL_DIR;
}