Add SSH launcher and cached remote runtime

This commit is contained in:
2026-05-10 22:48:11 +00:00
parent 138eea3231
commit 61f6d37960
11 changed files with 1513 additions and 102 deletions
+367 -3
View File
@@ -34,6 +34,10 @@ export interface ISshTunnelOptions extends ISshRunOptions {
remotePort: number;
}
export interface ISshUploadOptions extends ISshRunOptions {
cleanRemote?: boolean;
}
export interface ISshTunnelHandle {
readonly target: IIdeSshTarget;
readonly localHost: string;
@@ -44,6 +48,16 @@ export interface ISshTunnelHandle {
dispose(signal?: NodeJS.Signals): Promise<void>;
}
export interface ISshHostSaveInput {
alias: string;
hostName: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
forwardAgent?: boolean;
}
const directiveAliases: Record<string, keyof ISshHostConfig | 'identityFiles'> = {
hostname: 'hostName',
user: 'user',
@@ -105,9 +119,70 @@ export const parseSshConfig = (configText: string): ISshHostConfig[] => {
};
export const readSshConfig = async (filePath = defaultSshConfigPath()) => {
return readSshConfigRecursive(filePath, new Set());
};
export const renderSshHostBlock = (input: ISshHostSaveInput) => {
const host = normalizeSshHostSaveInput(input);
const lines = [`Host ${host.alias}`, ` HostName ${host.hostName}`];
if (host.user) {
lines.push(` User ${host.user}`);
}
if (host.port) {
lines.push(` Port ${host.port}`);
}
if (host.identityFile) {
lines.push(` IdentityFile ${host.identityFile}`);
}
if (host.proxyJump) {
lines.push(` ProxyJump ${host.proxyJump}`);
}
if (host.forwardAgent !== undefined) {
lines.push(` ForwardAgent ${host.forwardAgent ? 'yes' : 'no'}`);
}
return `${lines.join('\n')}\n`;
};
export const saveSshHostConfig = async (
input: ISshHostSaveInput,
filePath = defaultSshConfigPath(),
) => {
const host = normalizeSshHostSaveInput(input);
const expandedPath = expandHome(filePath);
let configText = '';
try {
configText = await plugins.fs.readFile(expandedPath, 'utf8');
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
throw error;
}
}
const nextConfigText = upsertSshHostBlock(configText, host);
await plugins.fs.mkdir(plugins.path.dirname(expandedPath), { recursive: true, mode: 0o700 });
await plugins.fs.writeFile(expandedPath, nextConfigText, { mode: 0o600 });
try {
await plugins.fs.chmod(expandedPath, 0o600);
} catch {}
return parseSshConfig(nextConfigText).find((parsedHost) => parsedHost.alias === host.alias)!;
};
const readSshConfigRecursive = async (filePath: string, visitedFiles: Set<string>) => {
const expandedPath = expandHome(filePath);
if (visitedFiles.has(expandedPath)) {
return [];
}
visitedFiles.add(expandedPath);
try {
const configText = await plugins.fs.readFile(expandHome(filePath), 'utf8');
return parseSshConfig(configText);
const hosts = parseSshConfig(configText);
for (const includePath of await resolveSshIncludePaths(configText, plugins.path.dirname(expandedPath))) {
hosts.push(...await readSshConfigRecursive(includePath, visitedFiles));
}
return hosts;
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
@@ -145,11 +220,12 @@ export const runSshCommand = async (
): Promise<ISshRunResult> => {
const executable = options.executable ?? 'ssh';
const args = buildSshArgs(target, remoteCommand, options);
const env = withSshAgentEnv(options.env ?? process.env);
return new Promise<ISshRunResult>((resolve, reject) => {
const child = plugins.childProcess.spawn(executable, args, {
cwd: options.cwd,
env: options.env ?? process.env,
env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
@@ -224,7 +300,7 @@ export const startSshTunnel = (
];
const child = plugins.childProcess.spawn(executable, args, {
cwd: options.cwd,
env: options.env ?? process.env,
env: withSshAgentEnv(options.env ?? process.env),
shell: false,
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
@@ -259,6 +335,81 @@ export const startSshTunnel = (
};
};
export const uploadDirectoryToRemote = async (
target: IIdeSshTarget,
localDirectory: string,
remoteDirectory: string,
options: ISshUploadOptions = {},
): Promise<ISshRunResult> => {
const remoteCommand = [
'set -euo pipefail',
options.cleanRemote === false ? undefined : `rm -rf ${quoteShellArg(remoteDirectory)}`,
`mkdir -p ${quoteShellArg(remoteDirectory)}`,
`tar -xzf - -C ${quoteShellArg(remoteDirectory)}`,
].filter(Boolean).join('\n');
const sshArgs = buildSshArgs(target, remoteCommand, options);
const env = withSshAgentEnv(options.env ?? process.env);
return new Promise<ISshRunResult>((resolve, reject) => {
const tar = plugins.childProcess.spawn('tar', ['-czf', '-', '-C', localDirectory, '.'], {
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
const ssh = plugins.childProcess.spawn(options.executable ?? 'ssh', sshArgs, {
cwd: options.cwd,
env,
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let tarStderr = '';
let settled = false;
const timeout = options.timeoutMs
? setTimeout(() => {
tar.kill('SIGTERM');
ssh.kill('SIGTERM');
finish({ exitCode: 1, stdout: '', stderr: `remote upload timed out after ${options.timeoutMs}ms` });
}, options.timeoutMs)
: undefined;
const finish = (result: ISshRunResult) => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
resolve(result);
};
tar.stdout.pipe(ssh.stdin);
tar.stderr.on('data', (chunk: Buffer) => {
tarStderr += chunk.toString('utf8');
});
tar.on('error', reject);
ssh.on('error', reject);
ssh.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
ssh.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
tar.on('close', (exitCode) => {
if (exitCode !== 0) {
ssh.kill('SIGTERM');
finish({ exitCode: exitCode ?? 1, stdout: '', stderr: tarStderr || `tar exited with code ${exitCode}` });
}
});
ssh.on('close', (exitCode) => {
finish({
exitCode: exitCode ?? 1,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: [tarStderr, Buffer.concat(stderr).toString('utf8')].filter(Boolean).join('\n'),
});
});
});
};
export const probeRemoteHost = async (
target: IIdeSshTarget,
options: ISshRunOptions = {},
@@ -336,12 +487,225 @@ const buildSshOptionArgs = (target: IIdeSshTarget, options: ISshRunOptions = {})
}
args.push('-o', 'ServerAliveInterval=30');
args.push('-o', 'ServerAliveCountMax=3');
if (target.hostName && target.hostName !== target.hostAlias) {
args.push('-o', `HostName=${target.hostName}`);
}
if (target.port) {
args.push('-p', `${target.port}`);
}
return args;
};
const withSshAgentEnv = (env: NodeJS.ProcessEnv) => {
const nextEnv = { ...env };
if (isSocketPath(nextEnv.SSH_AUTH_SOCK)) {
return nextEnv;
}
const agentEnvSocket = readAgentEnvSocket();
if (isSocketPath(agentEnvSocket)) {
nextEnv.SSH_AUTH_SOCK = agentEnvSocket;
return nextEnv;
}
const tmpSocket = findTmpAgentSocket();
if (tmpSocket) {
nextEnv.SSH_AUTH_SOCK = tmpSocket;
}
return nextEnv;
};
const quoteShellArg = (value: string | number | boolean) => {
const stringValue = String(value);
if (stringValue.length === 0) {
return "''";
}
return `'${stringValue.replace(/'/g, `'"'"'`)}'`;
};
const readAgentEnvSocket = () => {
const agentEnvPath = plugins.path.join(plugins.os.homedir(), '.ssh', 'agent.env');
try {
const agentEnvText = plugins.fsSync.readFileSync(agentEnvPath, 'utf8');
return agentEnvText.match(/(?:^|\n)SSH_AUTH_SOCK=([^;\n]+)/)?.[1];
} catch {
return undefined;
}
};
const findTmpAgentSocket = () => {
const candidates: Array<{ path: string; mtimeMs: number }> = [];
try {
for (const tmpEntry of plugins.fsSync.readdirSync('/tmp', { withFileTypes: true })) {
if (!tmpEntry.isDirectory() || !tmpEntry.name.startsWith('ssh-')) {
continue;
}
const directory = plugins.path.join('/tmp', tmpEntry.name);
try {
for (const socketEntry of plugins.fsSync.readdirSync(directory, { withFileTypes: true })) {
if (!socketEntry.name.startsWith('agent.')) {
continue;
}
const socketPath = plugins.path.join(directory, socketEntry.name);
if (isSocketPath(socketPath)) {
candidates.push({ path: socketPath, mtimeMs: plugins.fsSync.statSync(socketPath).mtimeMs });
}
}
} catch {}
}
} catch {}
return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path;
};
const isSocketPath = (filePath: string | undefined) => {
if (!filePath) {
return false;
}
try {
return plugins.fsSync.statSync(filePath).isSocket();
} catch {
return false;
}
};
const normalizeSshHostSaveInput = (input: ISshHostSaveInput): ISshHostSaveInput => {
const host: ISshHostSaveInput = {
alias: input.alias.trim(),
hostName: input.hostName.trim(),
user: input.user?.trim() || undefined,
port: input.port,
identityFile: input.identityFile?.trim() || undefined,
proxyJump: input.proxyJump?.trim() || undefined,
forwardAgent: input.forwardAgent,
};
validateSshToken(host.alias, 'Host alias');
validateSshToken(host.hostName, 'HostName');
if (host.user) {
validateSshToken(host.user, 'User');
}
if (host.identityFile) {
validateSshToken(host.identityFile, 'IdentityFile');
}
if (host.proxyJump) {
validateSshToken(host.proxyJump, 'ProxyJump');
}
if (host.port !== undefined && (!Number.isInteger(host.port) || host.port <= 0 || host.port > 65535)) {
throw new Error('Port must be a number from 1 to 65535.');
}
return host;
};
const validateSshToken = (value: string, label: string) => {
if (!value) {
throw new Error(`${label} is required.`);
}
if (/\s/.test(value)) {
throw new Error(`${label} must not contain whitespace.`);
}
};
const upsertSshHostBlock = (configText: string, host: ISshHostSaveInput) => {
const blockText = renderSshHostBlock(host).trimEnd();
const lines = configText.split(/\r?\n/);
const hostBlocks = findSshHostBlocks(lines);
const exactBlock = hostBlocks.find((block) => block.patterns.length === 1 && block.patterns[0] === host.alias);
if (exactBlock) {
lines.splice(exactBlock.start, exactBlock.end - exactBlock.start, ...blockText.split('\n'));
return `${trimTrailingBlankLines(lines).join('\n')}\n`;
}
const conflictingBlock = hostBlocks.find((block) => block.patterns.includes(host.alias));
if (conflictingBlock) {
throw new Error(`Host ${host.alias} is part of a multi-host block. Edit ${defaultSshConfigPath()} manually or choose a new alias.`);
}
const trimmedConfig = configText.trimEnd();
if (!trimmedConfig) {
return `${blockText}\n`;
}
return `${trimmedConfig}\n\n${blockText}\n`;
};
const findSshHostBlocks = (lines: string[]) => {
const blocks: Array<{ start: number; end: number; patterns: string[] }> = [];
for (let index = 0; index < lines.length; index++) {
const tokens = tokenizeSshConfigLine(stripSshComment(lines[index]!).trim());
if (tokens[0]?.toLowerCase() !== 'host') {
continue;
}
const nextHostIndex = findNextHostLine(lines, index + 1);
blocks.push({
start: index,
end: nextHostIndex === -1 ? lines.length : nextHostIndex,
patterns: tokens.slice(1),
});
}
return blocks;
};
const findNextHostLine = (lines: string[], startIndex: number) => {
for (let index = startIndex; index < lines.length; index++) {
const tokens = tokenizeSshConfigLine(stripSshComment(lines[index]!).trim());
if (tokens[0]?.toLowerCase() === 'host') {
return index;
}
}
return -1;
};
const trimTrailingBlankLines = (lines: string[]) => {
const nextLines = [...lines];
while (nextLines.length > 0 && !nextLines[nextLines.length - 1]!.trim()) {
nextLines.pop();
}
return nextLines;
};
const resolveSshIncludePaths = async (configText: string, baseDir: string) => {
const includePaths: string[] = [];
for (const rawLine of configText.split(/\r?\n/)) {
const tokens = tokenizeSshConfigLine(stripSshComment(rawLine).trim());
if (tokens[0]?.toLowerCase() !== 'include') {
continue;
}
for (const includePattern of tokens.slice(1)) {
includePaths.push(...await expandSshIncludePattern(includePattern, baseDir));
}
}
return includePaths;
};
const expandSshIncludePattern = async (includePattern: string, baseDir: string) => {
const expandedPattern = expandHome(includePattern);
const absolutePattern = plugins.path.isAbsolute(expandedPattern)
? expandedPattern
: plugins.path.resolve(baseDir, expandedPattern);
const directory = plugins.path.dirname(absolutePattern);
const basename = plugins.path.basename(absolutePattern);
if (!/[?*]/.test(basename)) {
return [absolutePattern];
}
try {
const entries = await plugins.fs.readdir(directory, { withFileTypes: true });
const matcher = wildcardToRegExp(basename);
return entries
.filter((entry) => entry.isFile() && matcher.test(entry.name))
.map((entry) => plugins.path.join(directory, entry.name))
.sort();
} catch {
return [];
}
};
const wildcardToRegExp = (pattern: string) => {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
};
const stripSshComment = (line: string) => {
let quote: string | undefined;
for (let index = 0; index < line.length; index++) {