import type { IIdeSshTarget, IRemoteProbeResult } from '@git.zone/ide-protocol'; import * as plugins from './plugins.js'; export interface ISshHostConfig { alias: string; patterns: string[]; hostName?: string; user?: string; port?: number; identityFiles: string[]; proxyJump?: string; forwardAgent?: boolean; raw: Record; } export interface ISshRunOptions { executable?: string; timeoutMs?: number; batchMode?: boolean; cwd?: string; env?: NodeJS.ProcessEnv; } export interface ISshRunResult { exitCode: number; stdout: string; stderr: string; } export interface ISshTunnelOptions extends ISshRunOptions { localHost?: string; localPort: number; remoteHost?: string; remotePort: number; } export interface ISshUploadOptions extends ISshRunOptions { cleanRemote?: boolean; } export interface ISshTunnelHandle { readonly target: IIdeSshTarget; readonly localHost: string; readonly localPort: number; readonly remoteHost: string; readonly remotePort: number; readonly processId: number | undefined; dispose(signal?: NodeJS.Signals): Promise; } export interface ISshHostSaveInput { alias: string; hostName: string; user?: string; port?: number; identityFile?: string; proxyJump?: string; forwardAgent?: boolean; } const directiveAliases: Record = { hostname: 'hostName', user: 'user', port: 'port', identityfile: 'identityFiles', proxyjump: 'proxyJump', forwardagent: 'forwardAgent', }; export const defaultSshConfigPath = () => plugins.path.join(plugins.os.homedir(), '.ssh', 'config'); export const expandHome = (filePath: string) => { if (filePath === '~') { return plugins.os.homedir(); } if (filePath.startsWith('~/')) { return plugins.path.join(plugins.os.homedir(), filePath.slice(2)); } return filePath; }; export const parseSshConfig = (configText: string): ISshHostConfig[] => { const hosts: ISshHostConfig[] = []; let currentHosts: ISshHostConfig[] = []; for (const rawLine of configText.split(/\r?\n/)) { const line = stripSshComment(rawLine).trim(); if (!line) { continue; } const tokens = tokenizeSshConfigLine(line); if (tokens.length === 0) { continue; } const key = tokens[0]!.toLowerCase(); const values = tokens.slice(1); if (key === 'host') { currentHosts = values.map((alias) => ({ alias, patterns: values, identityFiles: [], raw: {}, })); hosts.push(...currentHosts); continue; } for (const host of currentHosts) { host.raw[key] = [...(host.raw[key] ?? []), ...values]; applySshDirective(host, key, values); } } return hosts; }; 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) => { const expandedPath = expandHome(filePath); if (visitedFiles.has(expandedPath)) { return []; } visitedFiles.add(expandedPath); try { const configText = await plugins.fs.readFile(expandHome(filePath), 'utf8'); 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') { return []; } throw error; } }; export const listConnectableHosts = (hosts: ISshHostConfig[]) => { return hosts.filter((host) => !isWildcardHostPattern(host.alias) && !host.alias.startsWith('!')); }; export const buildSshDestination = (target: Pick) => { return target.user ? `${target.user}@${target.hostAlias}` : target.hostAlias; }; export const buildSshArgs = ( target: IIdeSshTarget, remoteCommand?: string, options: ISshRunOptions = {}, ) => { const args = buildSshOptionArgs(target, options); args.push(buildSshDestination(target)); if (remoteCommand) { args.push(remoteCommand); } return args; }; export const runSshCommand = async ( target: IIdeSshTarget, remoteCommand: string, options: ISshRunOptions = {}, ): Promise => { const executable = options.executable ?? 'ssh'; const args = buildSshArgs(target, remoteCommand, options); const env = withSshAgentEnv(options.env ?? process.env); return new Promise((resolve, reject) => { const child = plugins.childProcess.spawn(executable, args, { cwd: options.cwd, env, shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let finished = false; const timeout = options.timeoutMs ? setTimeout(() => { if (!finished) { child.kill('SIGTERM'); } }, options.timeoutMs) : undefined; child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); child.on('error', (error) => { finished = true; if (timeout) { clearTimeout(timeout); } reject(error); }); child.on('close', (exitCode) => { finished = true; if (timeout) { clearTimeout(timeout); } resolve({ exitCode: exitCode ?? 1, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), }); }); }); }; export const findFreePort = async (host = '127.0.0.1') => { return new Promise((resolve, reject) => { const server = plugins.net.createServer(); server.unref(); server.on('error', reject); server.listen(0, host, () => { const address = server.address(); server.close(() => { if (typeof address === 'object' && address) { resolve(address.port); } else { reject(new Error('Unable to allocate a free local port')); } }); }); }); }; export const startSshTunnel = ( target: IIdeSshTarget, options: ISshTunnelOptions, ): ISshTunnelHandle => { const executable = options.executable ?? 'ssh'; const localHost = options.localHost ?? '127.0.0.1'; const remoteHost = options.remoteHost ?? '127.0.0.1'; const forward = `${localHost}:${options.localPort}:${remoteHost}:${options.remotePort}`; const args = [ ...buildSshOptionArgs(target, options), '-N', '-L', forward, buildSshDestination(target), ]; const child = plugins.childProcess.spawn(executable, args, { cwd: options.cwd, env: withSshAgentEnv(options.env ?? process.env), shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true, }); return { target, localHost, localPort: options.localPort, remoteHost, remotePort: options.remotePort, processId: child.pid, dispose: async (signal: NodeJS.Signals = 'SIGTERM') => { if (child.exitCode !== null || child.killed) { return; } await new Promise((resolve) => { const timer = setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } resolve(); }, 5000); child.once('close', () => { clearTimeout(timer); resolve(); }); child.kill(signal); }); }, }; }; export const uploadDirectoryToRemote = async ( target: IIdeSshTarget, localDirectory: string, remoteDirectory: string, options: ISshUploadOptions = {}, ): Promise => { 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((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 = {}, ): Promise => { const command = [ `printf 'platform=%s\\n' "$(uname -s 2>/dev/null || printf unknown)"`, `printf 'arch=%s\\n' "$(uname -m 2>/dev/null || printf unknown)"`, `printf 'homeDir=%s\\n' "$HOME"`, `printf 'shell=%s\\n' "$SHELL"`, `printf 'nodeVersion=%s\\n' "$(node --version 2>/dev/null || true)"`, `printf 'pnpmVersion=%s\\n' "$(pnpm --version 2>/dev/null || true)"`, `printf 'gitVersion=%s\\n' "$(git --version 2>/dev/null || true)"`, `printf 'opencodeVersion=%s\\n' "$(opencode --version 2>/dev/null || true)"`, ].join('; '); const result = await runSshCommand(target, command, options); const fields = parseKeyValueLines(result.stdout); const errors: string[] = []; if (result.exitCode !== 0) { errors.push(result.stderr || `ssh exited with code ${result.exitCode}`); } return { ok: result.exitCode === 0, hostAlias: target.hostAlias, platform: fields.platform, arch: fields.arch, homeDir: fields.homeDir, shell: fields.shell, nodeVersion: fields.nodeVersion, pnpmVersion: fields.pnpmVersion, gitVersion: fields.gitVersion, opencodeVersion: fields.opencodeVersion, errors, }; }; const applySshDirective = (host: ISshHostConfig, key: string, values: string[]) => { const targetKey = directiveAliases[key]; if (!targetKey || values.length === 0) { return; } if (targetKey === 'identityFiles') { host.identityFiles.push(...values.map(expandHome)); return; } if (targetKey === 'port') { const port = Number(values[0]); if (Number.isInteger(port) && port > 0) { host.port = port; } return; } if (targetKey === 'forwardAgent') { host.forwardAgent = /^(yes|true)$/i.test(values[0]!); return; } if (targetKey === 'hostName') { host.hostName = values.join(' '); } else if (targetKey === 'user') { host.user = values.join(' '); } else if (targetKey === 'proxyJump') { host.proxyJump = values.join(' '); } }; const buildSshOptionArgs = (target: IIdeSshTarget, options: ISshRunOptions = {}) => { const args: string[] = []; if (options.batchMode !== false) { args.push('-o', 'BatchMode=yes'); } 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++) { const character = line[index]; if ((character === '"' || character === "'") && line[index - 1] !== '\\') { quote = quote === character ? undefined : character; continue; } if (character === '#' && !quote) { return line.slice(0, index); } } return line; }; const tokenizeSshConfigLine = (line: string) => { const tokens: string[] = []; let current = ''; let quote: string | undefined; for (let index = 0; index < line.length; index++) { const character = line[index]!; if ((character === '"' || character === "'") && line[index - 1] !== '\\') { quote = quote === character ? undefined : character; continue; } if (/\s/.test(character) && !quote) { if (current) { tokens.push(current); current = ''; } continue; } current += character; } if (current) { tokens.push(current); } return tokens; }; const isWildcardHostPattern = (alias: string) => /[*?!]/.test(alias); const parseKeyValueLines = (text: string) => { const fields: Record = {}; for (const line of text.split(/\r?\n/)) { const separator = line.indexOf('='); if (separator === -1) { continue; } fields[line.slice(0, separator)] = line.slice(separator + 1); } return fields; };