import fs from 'node:fs'; import fsp from 'node:fs/promises'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { replaceFileWithRetry } from './windows-file-replace.mjs'; const LOCAL_HOST_ID = 'local'; const DEFAULT_CONNECTION_TIMEOUT_SEC = 60; const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'; // Global npm prefixes are root-owned on most distributions, so `npm install -g` // fails with EACCES for a normal SSH user. Everything we install goes to a // prefix inside the user's home instead. const REMOTE_USER_PREFIX = '$HOME/.openchamber/npm-global'; const REMOTE_BUN_CANDIDATE = '"${BUN_INSTALL:-$HOME/.bun}/bin/bun"'; // The opencode CLI usually installs into the user's home, which an SSH login // shell does not have on PATH. The remote server only looks at OPENCODE_BINARY // and PATH, so resolve the CLI here and hand it over explicitly. const REMOTE_OPENCODE_CANDIDATES = [ '"$HOME/.opencode/bin/opencode"', '"${BUN_INSTALL:-$HOME/.bun}/bin/opencode"', '"$HOME/.local/bin/opencode"', '"$HOME/.openchamber/npm-global/bin/opencode"', ]; const REMOTE_PATH_PREFIX = '$HOME/.opencode/bin:${BUN_INSTALL:-$HOME/.bun}/bin:$HOME/.local/bin:$HOME/.openchamber/npm-global/bin'; const REMOTE_BIN_CANDIDATES = [ '"$HOME/.openchamber/npm-global/bin/openchamber"', '"${BUN_INSTALL:-$HOME/.bun}/bin/openchamber"', ]; const DEFAULT_CONTROL_PERSIST_SEC = 300; const DEFAULT_READY_TIMEOUT_SEC = 30; const DEFAULT_RECONNECT_MAX_ATTEMPTS = 5; const MAX_LOG_LINES_PER_INSTANCE = 1200; const MONITOR_INITIAL_POLL_MS = 2000; const MONITOR_STEADY_POLL_MS = 10000; const MONITOR_STABILIZE_TICKS = 5; const SSH_STATUS_EVENT = 'openchamber:ssh-instance-status'; const MAX_PROCESS_ERROR_CHARS = 2000; const MAX_PROCESS_ERROR_CAPTURE_CHARS = MAX_PROCESS_ERROR_CHARS * 2; const childProcessDiagnostics = new WeakMap(); const nowMillis = () => Date.now(); const shellQuote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`; const hasGlobWildcard = (value) => /[*?]/.test(value); const expandSshIncludeToken = (token, baseDir) => { const trimmed = String(token || '').trim(); if (!trimmed) return []; const expandedHome = trimmed.startsWith('~/') ? path.join(os.homedir(), trimmed.slice(2)) : (trimmed === '~' ? os.homedir() : trimmed); const resolved = path.isAbsolute(expandedHome) ? expandedHome : path.resolve(baseDir, expandedHome); if (!hasGlobWildcard(resolved)) { return fs.existsSync(resolved) ? [resolved] : []; } const dir = path.dirname(resolved); const namePattern = path.basename(resolved); if (hasGlobWildcard(dir) || !fs.existsSync(dir)) { return []; } const matcher = new RegExp(`^${namePattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') .replace(/\?/g, '.')}$`); try { return fs.readdirSync(dir) .filter((name) => matcher.test(name)) .map((name) => path.join(dir, name)) .filter((candidate) => fs.existsSync(candidate)) .sort((left, right) => left.localeCompare(right)); } catch { return []; } }; const readJsonRoot = (settingsFilePath) => { try { const parsed = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8')); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; } }; const writeJsonRoot = async (settingsFilePath, root) => { await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true }); // Atomic write: concurrent readers (main.mjs, web server) would otherwise // see partial JSON and readJsonRoot()'s catch would silently coerce to {}, // causing the next read-modify-write wipe the entire settings file. const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; try { await fsp.writeFile(tmp, JSON.stringify(root, null, 2)); await replaceFileWithRetry(tmp, settingsFilePath); } catch (error) { await fsp.rm(tmp, { force: true }).catch(() => {}); throw error; } }; const defaultTrue = () => true; const sanitizeBindHost = (raw) => { const trimmed = typeof raw === 'string' ? raw.trim() : ''; if (!trimmed) return DEFAULT_LOCAL_BIND_HOST; return ['127.0.0.1', 'localhost', '0.0.0.0'].includes(trimmed) ? trimmed : DEFAULT_LOCAL_BIND_HOST; }; const splitShellWords = (input) => { const tokens = []; let current = ''; let inSingle = false; let inDouble = false; const chars = [...String(input)]; for (let index = 0; index < chars.length; index += 1) { const ch = chars[index]; if (ch === '\\' && !inSingle) { index += 1; if (index < chars.length) current += chars[index]; continue; } if (ch === '\'' && !inDouble) { inSingle = !inSingle; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } if (/\s/.test(ch) && !inSingle && !inDouble) { if (current) { tokens.push(current); current = ''; } continue; } current += ch; } if (inSingle || inDouble) { throw new Error('Unclosed quote in SSH command'); } if (current) tokens.push(current); return tokens; }; const isDisallowedPrimaryFlag = (token) => { return ['-M', '-S', '-O', '-N', '-t', '-T', '-f', '-G', '-W', '-v', '-V', '-q', '-n', '-s', '-e', '-E', '-g'].includes(token); }; const hasDisallowedOOption = (value) => { const lower = String(value).trim().toLowerCase(); return ['controlmaster', 'controlpath', 'controlpersist', 'batchmode', 'proxycommand'].some((prefix) => lower.startsWith(prefix)); }; const parseSshCommand = (raw) => { const tokens = splitShellWords(raw); if (tokens.length === 0) { throw new Error('SSH command is empty'); } if (tokens[0] === 'ssh') { tokens.shift(); } if (tokens.length === 0) { throw new Error('SSH command must include destination'); } const allowedFlags = new Set(['-4', '-6', '-A', '-a', '-C', '-K', '-k', '-X', '-x', '-Y', '-y']); const allowedWithValues = ['-B', '-b', '-c', '-D', '-F', '-I', '-i', '-J', '-l', '-m', '-o', '-P', '-p', '-R']; const args = []; let destination = null; for (let index = 0; index < tokens.length;) { const token = tokens[index]; if (destination) { throw new Error(`SSH command has unsupported trailing argument: ${token}`); } if (!token.startsWith('-')) { destination = token.trim(); index += 1; continue; } if (isDisallowedPrimaryFlag(token)) { throw new Error(`SSH option ${token} is not allowed`); } if (allowedFlags.has(token)) { args.push(token); index += 1; continue; } let matched = false; for (const option of allowedWithValues) { if (token === option) { const value = tokens[index + 1]; if (!value) { throw new Error(`SSH option ${option} requires a value`); } if (option === '-o' && hasDisallowedOOption(value)) { throw new Error(`SSH option -o ${value} is not allowed`); } args.push(token, value); index += 2; matched = true; break; } if (token.startsWith(option) && token.length > option.length) { const value = token.slice(option.length); if (option === '-o' && hasDisallowedOOption(value)) { throw new Error(`SSH option -o ${value} is not allowed`); } args.push(token); index += 1; matched = true; break; } } if (!matched) { throw new Error(`Unsupported SSH option: ${token}`); } } if (!destination) { throw new Error('SSH command must include destination'); } return { destination, args }; }; const buildSshArgs = (parsed, preDestinationArgs = [], remoteCommand = null) => { const args = [...parsed.args, ...preDestinationArgs, parsed.destination]; if (remoteCommand) args.push(remoteCommand); return args; }; const askpassScriptContent = () => `#!/bin/bash PROMPT="$1" if [[ -n "$OPENCHAMBER_SSH_ASKPASS_VALUE" ]]; then if [[ "$PROMPT" == *"assword"* || "$PROMPT" == *"passphrase"* ]]; then printf '%s\\n' "$OPENCHAMBER_SSH_ASKPASS_VALUE" exit 0 fi fi DEFAULT_ANSWER="" HIDDEN_INPUT="true" if [[ "$PROMPT" == *"yes/no"* ]]; then DEFAULT_ANSWER="yes" HIDDEN_INPUT="false" fi if command -v osascript >/dev/null 2>&1; then /usr/bin/osascript <<'APPLESCRIPT' "$PROMPT" "$DEFAULT_ANSWER" "$HIDDEN_INPUT" on run argv set promptText to item 1 of argv set defaultAnswer to item 2 of argv set hiddenInput to item 3 of argv try if hiddenInput is "true" then set response to display dialog promptText default answer defaultAnswer with hidden answer buttons {"Cancel", "OK"} default button "OK" else set response to display dialog promptText default answer defaultAnswer buttons {"Cancel", "OK"} default button "OK" end if return text returned of response on error error number -128 end try end run APPLESCRIPT exit $? fi printf '%s\\n' "$DEFAULT_ANSWER" `; const writeAskpassScript = async (scriptPath) => { await fsp.writeFile(scriptPath, askpassScriptContent(), { mode: 0o700 }); await fsp.chmod(scriptPath, 0o700); }; const windowsAskpassScriptContent = () => `$value = [Environment]::GetEnvironmentVariable('OPENCHAMBER_SSH_ASKPASS_VALUE') if ($null -ne $value) { [Console]::Out.WriteLine($value) } `; const windowsAskpassWrapperContent = () => `@echo off\r "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0askpass.ps1"\r `; const sanitizeProcessDiagnostic = (value, secret = '') => { let sanitized = String(value || '') .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '') .trim(); if (secret) sanitized = sanitized.split(secret).join('[redacted]'); if (sanitized.length > MAX_PROCESS_ERROR_CHARS) { sanitized = `${sanitized.slice(0, MAX_PROCESS_ERROR_CHARS - 3)}...`; } return sanitized; }; const randomPortCandidate = (seed) => { let hash = 0; const source = `${seed}:${Date.now()}`; for (let index = 0; index < source.length; index += 1) { hash = ((hash << 5) - hash + source.charCodeAt(index)) | 0; } const base = 20000; const span = 30000; return base + Math.abs(hash % span); }; const pickUnusedLocalPort = async () => { return await new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, '127.0.0.1', () => { const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; server.close((error) => error ? reject(error) : resolve(port)); }); server.on('error', reject); }); }; const isLocalPortAvailable = async (bindHost, port) => { return await new Promise((resolve) => { const server = net.createServer(); server.once('error', () => resolve(false)); server.listen(port, bindHost, () => { server.close(() => resolve(true)); }); }); }; const isLocalTunnelReachable = async (localPort) => { return await new Promise((resolve) => { const socket = net.createConnection({ host: '127.0.0.1', port: localPort }); const finish = (value) => { socket.destroy(); resolve(value); }; socket.once('connect', () => finish(true)); socket.once('error', () => finish(false)); socket.setTimeout(500, () => finish(false)); }); }; const waitLocalForwardReady = async (localPort) => { const deadline = Date.now() + (DEFAULT_READY_TIMEOUT_SEC * 1000); let pollMs = 250; while (Date.now() < deadline) { try { const response = await fetch(`http://127.0.0.1:${localPort}/health`, { signal: AbortSignal.timeout(1000) }); if (response.ok || response.status === 401) { return; } } catch { } await new Promise((resolve) => setTimeout(resolve, pollMs)); pollMs = Math.min(pollMs * 2, 2000); } throw new Error('Timed out waiting for forwarded OpenChamber health'); }; const parseVersionToken = (raw) => { for (const token of String(raw).split(/\s+/)) { let candidate = token.trim().replace(/^v/, ''); candidate = candidate.replace(/[,)]+$/g, ''); const parts = candidate.split('.'); if (parts.length >= 2 && parts.every((part) => /^\d+$/.test(part))) { return candidate; } } return null; }; const parseProbeStatusLine = (line, prefix) => { if (!line || !line.startsWith(prefix)) return null; const value = Number.parseInt(line.slice(prefix.length).trim(), 10); return Number.isFinite(value) ? value : null; }; const isAuthHttpStatus = (status) => status === 401 || status === 403; const isLivenessHttpStatus = (status) => (status >= 200 && status <= 299) || isAuthHttpStatus(status); export class ElectronSshManager { constructor(options) { this.settingsFilePath = options.settingsFilePath; this.appVersion = options.appVersion; this.emit = options.emit; this.platform = options.platform || process.platform; this.spawnProcess = options.spawn || spawn; this.logs = new Map(); this.statuses = new Map(); this.sessions = new Map(); this.monitorTimers = new Map(); this.reconnectAttempts = new Map(); this.connectAttempts = new Map(); this.connecting = new Map(); this.sshAuth = new WeakMap(); } usesControlMaster() { return this.platform !== 'win32'; } hiddenSpawnOptions() { return this.platform === 'win32' ? { windowsHide: true } : {}; } authEnvironment(parsed) { const auth = this.sshAuth.get(parsed); if (!auth) return process.env; return { ...process.env, SSH_ASKPASS_REQUIRE: 'force', SSH_ASKPASS: auth.askpassPath, DISPLAY: '1', ...(auth.sshPassword ? { OPENCHAMBER_SSH_ASKPASS_VALUE: auth.sshPassword.trim() } : {}), }; } independentConnectionArgs() { return [ '-o', 'ControlMaster=no', '-o', 'ControlPath=none', '-o', 'StrictHostKeyChecking=accept-new', ]; } trackSshProcess(child, parsed) { const diagnostics = { stderr: '', error: null, parsed }; childProcessDiagnostics.set(child, diagnostics); const auth = this.sshAuth.get(parsed); auth?.children.add(child); child.stderr?.on('data', (chunk) => { diagnostics.stderr = `${diagnostics.stderr}${chunk.toString()}`.slice(-MAX_PROCESS_ERROR_CAPTURE_CHARS); }); child.on('error', (error) => { diagnostics.error = error; }); child.on('close', () => { auth?.children.delete(child); }); return child; } processErrorDetail(child, fallback) { const diagnostics = childProcessDiagnostics.get(child); const auth = diagnostics ? this.sshAuth.get(diagnostics.parsed) : null; const detail = sanitizeProcessDiagnostic( diagnostics?.error instanceof Error ? diagnostics.error.message : diagnostics?.stderr, auth?.sshPassword, ); return detail || fallback; } spawnSsh(parsed, preDestinationArgs, options, remoteCommand = null) { const child = this.spawnProcess('ssh', buildSshArgs(parsed, preDestinationArgs, remoteCommand), { ...options, ...this.hiddenSpawnOptions(), env: this.authEnvironment(parsed), }); return this.trackSshProcess(child, parsed); } async runSshOutput(parsed, preDestinationArgs, remoteCommand = null) { return await new Promise((resolve, reject) => { const child = this.trackSshProcess(this.spawnProcess('ssh', buildSshArgs(parsed, preDestinationArgs, remoteCommand), { stdio: ['pipe', 'pipe', 'pipe'], ...this.hiddenSpawnOptions(), env: this.authEnvironment(parsed), }), parsed); let stdout = ''; let stderr = ''; child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr?.on('data', (chunk) => { stderr = `${stderr}${chunk.toString()}`.slice(-MAX_PROCESS_ERROR_CAPTURE_CHARS); }); child.on('error', () => { reject(new Error(this.processErrorDetail(child, 'Failed to start SSH process'))); }); child.on('close', (code) => { const auth = this.sshAuth.get(parsed); resolve({ code: typeof code === 'number' ? code : -1, stdout, stderr: sanitizeProcessDiagnostic(stderr, auth?.sshPassword), }); }); }); } async runRemoteCommand(parsed, controlPath, script, timeoutSec = DEFAULT_CONNECTION_TIMEOUT_SEC) { const connectionArgs = this.usesControlMaster() ? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`] : this.independentConnectionArgs(); const { code, stdout, stderr } = await this.runSshOutput(parsed, [ ...connectionArgs, '-o', `ConnectTimeout=${timeoutSec}`, '-T', ], `sh -lc ${shellQuote(script)}`); if (code !== 0) { const auth = this.sshAuth.get(parsed); throw new Error(sanitizeProcessDiagnostic(stderr || stdout, auth?.sshPassword) || 'Remote command failed'); } return stdout; } async controlMasterOperation(parsed, controlPath, op) { return await this.runSshOutput(parsed, [ '-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=3', '-O', op, ]); } async isControlMasterAlive(parsed, controlPath) { const { code } = await this.controlMasterOperation(parsed, controlPath, 'check'); return code === 0; } async stopControlMasterBestEffort(parsed, controlPath) { if (!this.usesControlMaster()) return; try { await this.controlMasterOperation(parsed, controlPath, 'exit'); } catch { } } async writeAskpassFiles(sessionDir) { if (this.platform === 'win32') { const scriptPath = path.join(sessionDir, 'askpass.ps1'); const wrapperPath = path.join(sessionDir, 'askpass.cmd'); try { await fsp.writeFile(scriptPath, windowsAskpassScriptContent()); await fsp.writeFile(wrapperPath, windowsAskpassWrapperContent()); } catch (error) { await Promise.allSettled([ fsp.rm(scriptPath, { force: true }), fsp.rm(wrapperPath, { force: true }), ]); throw error; } return { askpassPath: wrapperPath, cleanupPaths: [wrapperPath, scriptPath] }; } const askpassPath = path.join(sessionDir, 'askpass.sh'); await writeAskpassScript(askpassPath); return { askpassPath, cleanupPaths: [askpassPath] }; } appendLogWithLevel(id, level, message) { const line = `[${nowMillis()}] [${level}] ${message}`; const current = this.logs.get(id) || []; current.push(line); if (current.length > MAX_LOG_LINES_PER_INSTANCE) { current.splice(0, current.length - MAX_LOG_LINES_PER_INSTANCE); } this.logs.set(id, current); } appendLog(id, message) { this.appendLogWithLevel(id, 'INFO', message); } appendAttemptSeparator(id, connectAttempt, retryAttempt) { const scope = retryAttempt > 0 ? `retry ${retryAttempt}` : 'manual'; this.appendLogWithLevel(id, 'INFO', `---------------- attempt #${connectAttempt} (${scope}) ----------------`); } statusSnapshotForInstance(id) { return this.statuses.get(id) || { id, phase: 'idle', detail: null, localUrl: null, localPort: null, remotePort: null, remoteBinPath: null, startedByUs: false, retryAttempt: 0, requiresUserAction: false, updatedAtMs: nowMillis(), }; } setStatus(id, phase, detail = null, localUrl = null, localPort = null, remotePort = null, startedByUs = false, retryAttempt = 0, requiresUserAction = false) { const level = phase === 'error' ? 'ERROR' : (phase === 'degraded' ? 'WARN' : 'INFO'); this.appendLogWithLevel( id, level, `phase=${JSON.stringify(phase)} detail=${detail || ''} retry=${retryAttempt} requires_user_action=${requiresUserAction}`, ); const status = { id, phase, detail, localUrl, localPort, remotePort, startedByUs, retryAttempt, requiresUserAction, updatedAtMs: nowMillis(), }; this.statuses.set(id, status); this.emit(SSH_STATUS_EVENT, status); } clearRetryAttempt(id) { this.reconnectAttempts.delete(id); } nextRetryAttempt(id) { const next = (this.reconnectAttempts.get(id) || 0) + 1; this.reconnectAttempts.set(id, next); return next; } currentRetryAttempt(id) { return this.reconnectAttempts.get(id) || 0; } nextConnectAttempt(id) { const next = (this.connectAttempts.get(id) || 0) + 1; this.connectAttempts.set(id, next); return next; } logsForInstance(id, limit = 200) { const lines = [...(this.logs.get(id) || [])]; return limit > 0 && lines.length > limit ? lines.slice(-limit) : lines; } clearLogsForInstance(id) { this.logs.delete(id); } parseSshConfigCandidates(filePath, source, visited = new Set()) { const resolvedPath = path.resolve(filePath); if (visited.has(resolvedPath) || !fs.existsSync(resolvedPath)) return []; visited.add(resolvedPath); const content = fs.readFileSync(resolvedPath, 'utf8'); const candidates = []; const baseDir = path.dirname(resolvedPath); for (const line of content.split(/\r?\n/)) { const trimmed = (line.split('#')[0] || '').trim(); if (!trimmed) continue; if (/^include(?:\s|$)/i.test(trimmed)) { const includeExpr = trimmed.replace(/^include\s+/i, '').trim(); if (!includeExpr) continue; let includeTokens = []; try { includeTokens = splitShellWords(includeExpr); } catch { includeTokens = includeExpr.split(/\s+/).filter(Boolean); } for (const includeToken of includeTokens) { const includePaths = expandSshIncludeToken(includeToken, baseDir); for (const includePath of includePaths) { candidates.push(...this.parseSshConfigCandidates(includePath, source, visited)); } } continue; } if (!/^host(?:\s|$)/i.test(trimmed)) continue; const rest = trimmed.replace(/^host\s+/i, '').trim(); if (!rest) continue; for (const token of rest.split(/\s+/)) { const host = token.trim(); if (!host || host.startsWith('!') || host === '*') continue; candidates.push({ host, pattern: /[*?]/.test(host), source, sshCommand: `ssh ${host}`, }); } } return candidates; } async importHosts() { const candidates = [ ...this.parseSshConfigCandidates(path.join(os.homedir(), '.ssh', 'config'), 'user'), ...this.parseSshConfigCandidates('/etc/ssh/ssh_config', 'global'), ]; const seen = new Set(); return candidates .filter((item) => !seen.has(item.host) && seen.add(item.host)) .sort((left, right) => left.host.localeCompare(right.host)); } readInstances() { const root = readJsonRoot(this.settingsFilePath); return { instances: Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : [] }; } async setInstances(config) { const root = readJsonRoot(this.settingsFilePath); const previousSshIds = new Set( (Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : []) .map((entry) => String(entry?.id || '').trim()) .filter((id) => id && id !== LOCAL_HOST_ID) ); const instances = Array.isArray(config?.instances) ? config.instances.map((instance) => this.sanitizeInstance(instance)) : []; root.desktopSshInstances = instances; const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts.filter(Boolean) : []; const nextIds = new Set(instances.map((instance) => instance.id)); const filteredHosts = hosts.filter((entry) => { const id = String(entry?.id || '').trim(); return id && id !== LOCAL_HOST_ID && !(previousSshIds.has(id) && !nextIds.has(id)); }); for (const instance of instances) { const label = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id; const existing = filteredHosts.find((entry) => entry?.id === instance.id); if (existing) { existing.label = label; if (!existing.url || !String(existing.url).trim()) { existing.url = 'http://127.0.0.1/'; } } else { filteredHosts.push({ id: instance.id, label, url: 'http://127.0.0.1/' }); } } root.desktopHosts = filteredHosts; if (typeof root.desktopDefaultHostId === 'string' && previousSshIds.has(root.desktopDefaultHostId) && !nextIds.has(root.desktopDefaultHostId)) { root.desktopDefaultHostId = LOCAL_HOST_ID; } await writeJsonRoot(this.settingsFilePath, root); } sanitizeStoredSecret(secret) { if (!secret || typeof secret !== 'object') return undefined; return { enabled: Boolean(secret.enabled), store: secret.store === 'settings' ? 'settings' : 'never', ...(typeof secret.value === 'string' && secret.value.trim() ? { value: secret.value } : {}), }; } sanitizeForward(forward) { const id = typeof forward?.id === 'string' ? forward.id.trim() : ''; if (!id) return null; const type = forward?.type === 'remote' || forward?.type === 'dynamic' ? forward.type : 'local'; const normalized = { id, enabled: forward?.enabled !== false, type, ...(forward?.localHost ? { localHost: sanitizeBindHost(forward.localHost) } : {}), ...(Number.isFinite(forward?.localPort) ? { localPort: Number(forward.localPort) } : {}), ...(forward?.remoteHost ? { remoteHost: String(forward.remoteHost).trim() || '127.0.0.1' } : {}), ...(Number.isFinite(forward?.remotePort) ? { remotePort: Number(forward.remotePort) } : {}), }; if (type === 'local' || type === 'remote') { if (!normalized.localPort || !normalized.remotePort) return null; normalized.remoteHost = normalized.remoteHost || '127.0.0.1'; normalized.localHost = normalized.localHost || '127.0.0.1'; } if (type === 'dynamic' && !normalized.localPort) { return null; } return normalized; } sanitizeInstance(instance) { const id = typeof instance?.id === 'string' ? instance.id.trim() : ''; const sshCommand = typeof instance?.sshCommand === 'string' ? instance.sshCommand.trim() : ''; if (!id || id === LOCAL_HOST_ID) { throw new Error('SSH instance id is required'); } if (!sshCommand) { throw new Error('SSH command is required'); } const parsed = parseSshCommand(sshCommand); const seen = new Set(); const portForwards = Array.isArray(instance?.portForwards) ? instance.portForwards .map((forward) => this.sanitizeForward(forward)) .filter((forward) => forward && !seen.has(forward.id) && seen.add(forward.id)) : []; return { id, ...(typeof instance?.nickname === 'string' && instance.nickname.trim() ? { nickname: instance.nickname.trim() } : {}), sshCommand, sshParsed: parsed, connectionTimeoutSec: Number.isFinite(instance?.connectionTimeoutSec) && Number(instance.connectionTimeoutSec) > 0 ? Number(instance.connectionTimeoutSec) : DEFAULT_CONNECTION_TIMEOUT_SEC, remoteOpenchamber: { mode: instance?.remoteOpenchamber?.mode === 'external' ? 'external' : 'managed', keepRunning: instance?.remoteOpenchamber?.keepRunning !== false, ...(Number.isFinite(instance?.remoteOpenchamber?.preferredPort) ? { preferredPort: Number(instance.remoteOpenchamber.preferredPort) } : {}), installMethod: ['auto', 'npm', 'bun'].includes(instance?.remoteOpenchamber?.installMethod) ? instance.remoteOpenchamber.installMethod : 'auto', bindHost: instance?.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1', uploadBundleOverSsh: Boolean(instance?.remoteOpenchamber?.uploadBundleOverSsh), }, localForward: { bindHost: sanitizeBindHost(instance?.localForward?.bindHost), ...(Number.isFinite(instance?.localForward?.preferredLocalPort) ? { preferredLocalPort: Number(instance.localForward.preferredLocalPort) } : {}), }, auth: { ...(this.sanitizeStoredSecret(instance?.auth?.sshPassword) ? { sshPassword: this.sanitizeStoredSecret(instance.auth.sshPassword) } : {}), ...(this.sanitizeStoredSecret(instance?.auth?.openchamberPassword) ? { openchamberPassword: this.sanitizeStoredSecret(instance.auth.openchamberPassword) } : {}), }, portForwards, }; } async updateHostUrl(instanceId, label, localUrl) { return this.updateHostRuntime(instanceId, label, localUrl, ''); } async updateHostRuntime(instanceId, label, localUrl, clientToken = '') { const root = readJsonRoot(this.settingsFilePath); const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts : []; const existing = hosts.find((entry) => entry?.id === instanceId); const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (existing) { existing.label = label; existing.url = localUrl; existing.apiUrl = localUrl; if (token) existing.clientToken = token; } else { hosts.push({ id: instanceId, label, url: localUrl, apiUrl: localUrl, ...(token ? { clientToken: token } : {}) }); } root.desktopHosts = hosts; await writeJsonRoot(this.settingsFilePath, root); } async issueClientToken(localUrl, openchamberPassword) { const password = typeof openchamberPassword === 'string' ? openchamberPassword.trim() : ''; if (!password) return ''; const loginResponse = await fetch(new URL('/auth/session', `${localUrl}/`).toString(), { method: 'POST', signal: AbortSignal.timeout(10_000), headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Desktop SSH', }), }); if (!loginResponse.ok) { throw new Error(`Configured OpenChamber UI password was rejected by forwarded server (status ${loginResponse.status})`); } const payload = await loginResponse.json().catch(() => null); const token = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; if (token) return token; const cookie = this.extractCookieHeader(loginResponse); if (!cookie) return ''; const tokenResponse = await fetch(new URL('/api/client-auth/clients', `${localUrl}/`).toString(), { method: 'POST', signal: AbortSignal.timeout(10_000), headers: { Accept: 'application/json', 'Content-Type': 'application/json', Cookie: cookie, }, body: JSON.stringify({ label: 'OpenChamber Desktop SSH' }), }); if (!tokenResponse.ok) return ''; const tokenPayload = await tokenResponse.json().catch(() => null); return typeof tokenPayload?.token === 'string' ? tokenPayload.token.trim() : ''; } extractCookieHeader(response) { const getSetCookie = typeof response.headers?.getSetCookie === 'function' ? response.headers.getSetCookie.bind(response.headers) : null; const cookies = getSetCookie ? getSetCookie() : []; const rawCookies = cookies.length > 0 ? cookies : String(response.headers?.get?.('set-cookie') || '').split(/,(?=\s*[^;,=]+=[^;,]+)/); return rawCookies .map((cookie) => String(cookie || '').split(';')[0].trim()) .filter(Boolean) .join('; '); } async persistLocalPort(instanceId, localPort) { const root = readJsonRoot(this.settingsFilePath); const instances = Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : []; for (const instance of instances) { if (instance?.id !== instanceId) continue; instance.localForward = instance.localForward && typeof instance.localForward === 'object' ? instance.localForward : {}; instance.localForward.preferredLocalPort = localPort; } root.desktopSshInstances = instances; await writeJsonRoot(this.settingsFilePath, root); } async resolveSshConfig(parsed) { const { code, stdout, stderr } = await this.runSshOutput(parsed, ['-G']); if (code !== 0) { throw new Error(stderr.trim() || 'Failed to resolve SSH config'); } const map = new Map(); for (const line of stdout.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed) continue; const [key, ...rest] = trimmed.split(' '); if (!key || rest.length === 0) continue; map.set(key.toLowerCase(), rest.join(' ').trim()); } return map; } ensureSessionDir(id) { const base = path.join(path.dirname(this.settingsFilePath), 'ssh', id); fs.mkdirSync(base, { recursive: true }); return base; } controlPathForInstance(id) { let hash = 0; for (const char of id) { hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0; } return path.join(os.tmpdir(), `ocssh-${Math.abs(hash).toString(16)}.sock`); } async spawnMasterProcess(parsed, controlPath) { return this.spawnSsh(parsed, [ '-o', 'ControlMaster=yes', '-o', `ControlPath=${controlPath}`, '-o', `ControlPersist=${DEFAULT_CONTROL_PERSIST_SEC}`, '-N', ], { stdio: ['ignore', 'pipe', 'pipe'], }); } async waitForMasterReady(parsed, controlPath, timeoutSec, master) { const deadline = Date.now() + (timeoutSec * 1000); let pollMs = 250; while (Date.now() < deadline) { const { code } = await this.runSshOutput(parsed, [ '-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`, '-O', 'check', ]); if (code === 0) return; const exited = master.exitCode; if (typeof exited === 'number') { throw new Error(this.processErrorDetail(master, 'SSH master process exited before ready')); } const spawnError = childProcessDiagnostics.get(master)?.error; if (spawnError) throw new Error(this.processErrorDetail(master, 'Failed to start SSH master process')); await new Promise((resolve) => setTimeout(resolve, pollMs)); pollMs = Math.min(pollMs * 2, 2000); } throw new Error('SSH ControlMaster connection timed out'); } configuredOpenChamberPassword(instance) { const secret = instance?.auth?.openchamberPassword; return secret?.enabled && typeof secret.value === 'string' && secret.value.trim() ? secret.value.trim() : null; } // A login shell over SSH does not source the user's interactive rc files, so // tools installed into a home directory (bun above all) are missing from PATH // even when they exist. Look at their known install locations too. async resolveRemoteTool(parsed, controlPath, commandName, extraCandidates = []) { const candidateList = [...extraCandidates, `"$(command -v ${commandName} 2>/dev/null)"`].join(' '); const script = [ `for candidate in ${candidateList}; do`, ' [ -n "$candidate" ] || continue;', ' [ -x "$candidate" ] || continue;', ` printf '%s' "$candidate";`, ' exit 0;', 'done', ].join(' '); try { const output = await this.runRemoteCommand(parsed, controlPath, script); return output.trim() || null; } catch { return null; } } // Every place OpenChamber may live on the remote host, with the version each // one reports. Installs land in the user prefix while an older copy can still // sit on PATH, so the caller picks by version instead of trusting PATH order. async remoteOpenChamberCandidates(parsed, controlPath) { const script = [ `for candidate in ${REMOTE_BIN_CANDIDATES.join(' ')} "$(command -v openchamber 2>/dev/null)"; do`, ' [ -n "$candidate" ] || continue;', ' [ -x "$candidate" ] || continue;', ` printf '%s\t%s\n' "$candidate" "$("$candidate" --version 2>/dev/null | head -n 1)";`, 'done', ].join(' '); let output = ''; try { output = await this.runRemoteCommand(parsed, controlPath, script); } catch { return []; } const candidates = []; const seen = new Set(); for (const line of output.split(/\r?\n/)) { const [binPath, versionRaw] = line.split('\t'); const trimmed = (binPath || '').trim(); if (!trimmed || seen.has(trimmed)) continue; seen.add(trimmed); candidates.push({ binPath: trimmed, version: parseVersionToken(versionRaw || '') }); } return candidates; } async installOpenChamberManaged(parsed, controlPath, version, preferred) { const bunPath = await this.resolveRemoteTool(parsed, controlPath, 'bun', [REMOTE_BUN_CANDIDATE]); const npmPath = await this.resolveRemoteTool(parsed, controlPath, 'npm'); // bun's global install already targets ~/.bun; npm is pinned to a prefix in // the user's home so it never touches the root-owned global directory. const bunCommand = bunPath ? `${shellQuote(bunPath)} add -g @openchamber/web@${version}` : null; const npmCommand = npmPath ? `mkdir -p "${REMOTE_USER_PREFIX}" && ${shellQuote(npmPath)} install -g --prefix "${REMOTE_USER_PREFIX}" @openchamber/web@${version}` : null; const commands = []; if (preferred === 'npm') { if (npmCommand) commands.push(npmCommand); if (bunCommand) commands.push(bunCommand); } else { if (bunCommand) commands.push(bunCommand); if (npmCommand) commands.push(npmCommand); } if (commands.length === 0) { throw new Error('Remote host has neither bun nor npm available'); } let lastError = null; for (const command of commands) { try { await this.runRemoteCommand(parsed, controlPath, command); return; } catch (error) { lastError = error; } } throw lastError || new Error('Failed to install OpenChamber on remote host'); } async probeRemoteSystemInfo(parsed, controlPath, port, openchamberPassword) { const authPayload = openchamberPassword ? JSON.stringify({ password: openchamberPassword }) : '{}'; const authEnabled = openchamberPassword ? '1' : '0'; const script = `AUTH_STATUS=0; INFO_STATUS=0; HEALTH_STATUS=0; BODY_FILE="$(mktemp)"; COOKIE_FILE="$(mktemp)"; cleanup(){ rm -f "$BODY_FILE" "$COOKIE_FILE"; }; trap cleanup EXIT; if command -v curl >/dev/null 2>&1; then if [ "${authEnabled}" = "1" ]; then AUTH_STATUS="$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' -c "$COOKIE_FILE" -H 'content-type: application/json' --data ${shellQuote(authPayload)} http://127.0.0.1:${port}/auth/session || true)"; if [ "$AUTH_STATUS" = "200" ]; then INFO_STATUS="$(curl -sS --max-time 3 -b "$COOKIE_FILE" -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; else INFO_STATUS="$(curl -sS --max-time 3 -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; fi; else INFO_STATUS="$(curl -sS --max-time 3 -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; fi; HEALTH_STATUS="$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}/health || true)"; elif command -v wget >/dev/null 2>&1; then wget -qO "$BODY_FILE" http://127.0.0.1:${port}/api/system/info >/dev/null 2>&1; if [ $? -eq 0 ]; then INFO_STATUS=200; fi; wget -qO- http://127.0.0.1:${port}/health >/dev/null 2>&1; if [ $? -eq 0 ]; then HEALTH_STATUS=200; fi; else exit 127; fi; printf 'INFO_STATUS=%s\\nAUTH_STATUS=%s\\nHEALTH_STATUS=%s\\n' "$INFO_STATUS" "$AUTH_STATUS" "$HEALTH_STATUS"; cat "$BODY_FILE" 2>/dev/null || true`; const output = await this.runRemoteCommand(parsed, controlPath, script); const lines = output.split(/\r?\n/); const infoStatus = parseProbeStatusLine(lines[0], 'INFO_STATUS=') || 0; const authStatus = parseProbeStatusLine(lines[1], 'AUTH_STATUS=') || 0; const healthStatus = parseProbeStatusLine(lines[2], 'HEALTH_STATUS=') || 0; const body = lines.slice(3).join('\n'); if (isLivenessHttpStatus(infoStatus)) { if (isAuthHttpStatus(infoStatus)) { if (openchamberPassword && authStatus !== 200) { throw new Error(`Remote OpenChamber requires UI authentication and configured password was rejected (auth status ${authStatus})`); } if (isLivenessHttpStatus(healthStatus)) return {}; throw new Error('Remote OpenChamber requires UI authentication on /api/system/info; configure OpenChamber UI password'); } } else if (isLivenessHttpStatus(healthStatus)) { return {}; } else { throw new Error(`Remote OpenChamber probe failed (info status ${infoStatus}, health status ${healthStatus})`); } try { return JSON.parse(body); } catch { return {}; } } async remoteServerRunning(parsed, controlPath, port, openchamberPassword) { try { await this.probeRemoteSystemInfo(parsed, controlPath, port, openchamberPassword); return true; } catch { return false; } } async startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binPath) { const opencodePath = await this.resolveRemoteTool(parsed, controlPath, 'opencode', REMOTE_OPENCODE_CANDIDATES); if (!opencodePath) { throw new Error('The opencode CLI is not installed on the remote machine. Install it there, then connect again'); } const secret = this.configuredOpenChamberPassword(instance); const remoteBindHost = instance.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1'; // Binding the remote server to every interface publishes its UI to the // remote machine's whole network, so it may not run without a password. if (remoteBindHost === '0.0.0.0' && !secret) { throw new Error('Exposing the remote server to its network requires a UI password'); } let envPrefix = `PATH="${REMOTE_PATH_PREFIX}:$PATH" OPENCODE_BINARY=${shellQuote(opencodePath)} OPENCHAMBER_RUNTIME=ssh-remote`; if (secret) { envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`; } const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} ${shellQuote(binPath)} serve --hostname ${remoteBindHost} --port ${desiredPort}`); const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value)); return port || desiredPort; } // `openchamber stop` owns the daemon lifecycle. The HTTP shutdown route sits // behind UI authentication, so it cannot stop a password-protected server. async stopRemoteServerBestEffort(parsed, controlPath, remotePort, remoteBinPath) { if (!remoteBinPath) return; try { await this.runRemoteCommand(parsed, controlPath, `${shellQuote(remoteBinPath)} stop --port ${remotePort}`); } catch { } } async spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort) { const connectionArgs = this.usesControlMaster() ? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`] : this.independentConnectionArgs(); return this.spawnSsh(parsed, [ ...connectionArgs, '-o', 'ExitOnForwardFailure=yes', '-N', '-L', `${bindHost}:${localPort}:127.0.0.1:${remotePort}`, ], { stdio: ['ignore', 'ignore', 'pipe'], }); } async spawnExtraForward(parsed, controlPath, forward) { const args = this.usesControlMaster() ? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`, '-O', 'forward'] : [...this.independentConnectionArgs(), '-o', 'ExitOnForwardFailure=yes', '-N']; if (forward.type === 'local') { args.push('-L', `${forward.localHost || '127.0.0.1'}:${forward.localPort}:${forward.remoteHost || '127.0.0.1'}:${forward.remotePort}`); } else if (forward.type === 'remote') { args.push('-R', `${forward.remoteHost || '127.0.0.1'}:${forward.remotePort}:${forward.localHost || '127.0.0.1'}:${forward.localPort}`); } else { args.push('-D', `${forward.localHost || '127.0.0.1'}:${forward.localPort}`); } if (!this.usesControlMaster()) { const child = this.spawnSsh(parsed, args, { stdio: ['ignore', 'ignore', 'pipe'] }); await new Promise((resolve) => setTimeout(resolve, 250)); if (typeof child.exitCode === 'number' || childProcessDiagnostics.get(child)?.error) { throw new Error(this.processErrorDetail(child, `Failed to configure extra SSH forward ${forward.id}`)); } return child; } const { code, stdout, stderr } = await this.runSshOutput(parsed, args); if (code !== 0) { throw new Error((stderr || stdout || `Failed to configure extra SSH forward ${forward.id}`).trim()); } return null; } async ensureRemoteServer(instance, parsed, controlPath) { if (instance.remoteOpenchamber.mode === 'external') { if (!instance.remoteOpenchamber.preferredPort) { throw new Error('External mode requires a preferred remote OpenChamber port'); } const port = instance.remoteOpenchamber.preferredPort; this.setStatus(instance.id, 'server_detecting', 'Probing external OpenChamber server', null, null, port, false, 0, false); await this.probeRemoteSystemInfo(parsed, controlPath, port, this.configuredOpenChamberPassword(instance)); return { remotePort: port, startedByUs: false, remoteBinPath: null }; } this.setStatus(instance.id, 'remote_probe', 'Checking remote OpenChamber installation'); const installed = await this.remoteOpenChamberCandidates(parsed, controlPath); let binary = installed.find((candidate) => candidate.version === this.appVersion) || null; if (!binary) { const existing = installed[0] || null; if (existing) { this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${existing.version || 'unknown'} to ${this.appVersion}`); } else { this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host'); } await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod); const afterInstall = await this.remoteOpenChamberCandidates(parsed, controlPath); binary = afterInstall.find((candidate) => candidate.version === this.appVersion) || afterInstall[0] || existing; if (!binary) { throw new Error('OpenChamber was installed on the remote host but no openchamber binary could be found'); } } this.setStatus(instance.id, 'server_detecting', 'Detecting managed OpenChamber server'); let remotePort = instance.remoteOpenchamber.preferredPort || null; let startedByUs = false; if (remotePort && !(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) { remotePort = null; } if (!remotePort) { this.setStatus(instance.id, 'server_starting', 'Starting managed OpenChamber server'); const desiredPort = instance.remoteOpenchamber.preferredPort || randomPortCandidate(instance.id); remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binary.binPath); startedByUs = true; } if (!(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) { throw new Error('Managed OpenChamber server failed to become reachable'); } return { remotePort, startedByUs, remoteBinPath: binary.binPath }; } async disconnectInternal(id, reportIdle) { const timer = this.monitorTimers.get(id); if (timer) { clearTimeout(timer); this.monitorTimers.delete(id); } const session = this.sessions.get(id); this.sessions.delete(id); if (session) { if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) { await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort, session.remoteBinPath); } await this.stopControlMasterBestEffort(session.parsed, session.controlPath); const auth = this.sshAuth.get(session.parsed); const children = new Set([ session.mainForward, session.master, ...session.extraForwards.map((entry) => entry.child), ...(auth?.children || []), ].filter(Boolean)); for (const child of children) { try { child.kill('SIGTERM'); } catch { } } try { await fsp.rm(session.controlPath, { force: true }); } catch { } for (const askpassFilePath of session.askpassCleanupPaths) { try { await fsp.rm(askpassFilePath, { force: true }); } catch { } } this.sshAuth.delete(session.parsed); } this.clearRetryAttempt(id); if (reportIdle) { this.setStatus(id, 'idle', null, null, null, null, false, 0, false); } } async connectBlocking(instance) { const id = instance.id; this.setStatus(id, 'config_resolved', 'Resolving SSH command'); const parsed = instance.sshParsed || parseSshCommand(instance.sshCommand); await this.resolveSshConfig(parsed); this.setStatus(id, 'auth_check', 'Checking SSH connectivity'); const sessionDir = this.ensureSessionDir(id); const controlPath = this.controlPathForInstance(id); try { await fsp.rm(controlPath, { force: true }); } catch {} const { askpassPath, cleanupPaths: askpassCleanupPaths } = await this.writeAskpassFiles(sessionDir); const sshPassword = instance.auth?.sshPassword?.enabled ? instance.auth.sshPassword.value?.trim() : null; this.sshAuth.set(parsed, { askpassPath, sshPassword, children: new Set() }); const session = { instance, parsed, sessionDir, controlPath, askpassCleanupPaths, localPort: null, remotePort: null, startedByUs: false, master: null, mainForward: null, mainForwardDetached: false, extraForwards: [], }; this.sessions.set(id, session); this.setStatus(id, 'master_connecting', this.usesControlMaster() ? 'Establishing SSH ControlMaster' : 'Checking SSH connectivity'); if (this.usesControlMaster()) { session.master = await this.spawnMasterProcess(parsed, controlPath); await this.waitForMasterReady(parsed, controlPath, instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC, session.master); } this.setStatus(id, 'remote_probe', 'Probing remote platform'); const remoteOs = (await this.runRemoteCommand(parsed, controlPath, 'uname -s', instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC)).trim().toLowerCase(); if (!['linux', 'darwin'].includes(remoteOs)) { throw new Error(`Unsupported remote OS: ${remoteOs}`); } const { remotePort, startedByUs, remoteBinPath } = await this.ensureRemoteServer(instance, parsed, controlPath); session.remotePort = remotePort; session.startedByUs = startedByUs; session.remoteBinPath = remoteBinPath; this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false); const bindHost = sanitizeBindHost(instance.localForward?.bindHost); let localPort = Number(instance.localForward?.preferredLocalPort) || 0; if (!localPort) { localPort = await pickUnusedLocalPort(); } if (!(await isLocalPortAvailable(bindHost, localPort))) { localPort = await pickUnusedLocalPort(); } const mainForward = await this.spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort); session.mainForward = mainForward; let mainForwardDetached = false; await new Promise((resolve) => setTimeout(resolve, 250)); if (typeof mainForward.exitCode === 'number' || childProcessDiagnostics.get(mainForward)?.error) { if (this.usesControlMaster() && mainForward.exitCode === 0) { mainForwardDetached = true; this.appendLogWithLevel(id, 'INFO', 'Main tunnel helper exited after ControlMaster handoff'); } else { throw new Error(this.processErrorDetail(mainForward, `Failed to start main port forward (status: ${mainForward.exitCode ?? 'spawn error'})`)); } } session.mainForwardDetached = mainForwardDetached; const extraErrors = []; for (const forward of instance.portForwards.filter((item) => item.enabled)) { try { const extraForward = await this.spawnExtraForward(parsed, controlPath, forward); if (extraForward) session.extraForwards.push({ id: forward.id, child: extraForward }); if (forward.type === 'local' && forward.localPort) { await new Promise((resolve) => setTimeout(resolve, 100)); if (!(await isLocalTunnelReachable(forward.localPort))) { extraErrors.push(`${forward.id}: local listener 127.0.0.1:${forward.localPort} is not reachable`); } } } catch (error) { extraErrors.push(`${forward.id}: ${error instanceof Error ? error.message : String(error)}`); } } await waitLocalForwardReady(localPort); const localUrl = `http://127.0.0.1:${localPort}`; const label = instance.nickname?.trim() || parsed.destination || id; const clientToken = await this.issueClientToken(localUrl, this.configuredOpenChamberPassword(instance)); await this.updateHostRuntime(id, label, localUrl, clientToken); if (instance.localForward?.preferredLocalPort !== localPort) { await this.persistLocalPort(id, localPort); } session.localPort = localPort; this.clearRetryAttempt(id); this.setStatus( id, 'ready', extraErrors.length === 0 ? 'SSH instance is ready' : `SSH instance is ready with forward warnings: ${extraErrors.join('; ')}`, localUrl, localPort, remotePort, startedByUs, 0, false, ); this.spawnMonitor(id); } spawnMonitor(id) { const existing = this.monitorTimers.get(id); if (existing) clearTimeout(existing); let healthyTicks = 0; const tick = async () => { const session = this.sessions.get(id); if (!session) { this.monitorTimers.delete(id); return; } let droppedReason = null; let detachedNotice = null; if (!session.mainForwardDetached) { if (typeof session.mainForward.exitCode === 'number') { if (this.usesControlMaster() && session.mainForward.exitCode === 0) { session.mainForwardDetached = true; detachedNotice = 'Main tunnel helper exited after ControlMaster handoff'; } else { droppedReason = this.processErrorDetail(session.mainForward, `Main SSH forward exited (${session.mainForward.exitCode})`); } } else if (childProcessDiagnostics.get(session.mainForward)?.error) { droppedReason = this.processErrorDetail(session.mainForward, 'Main SSH forward failed'); } } if (!droppedReason) { const stoppedExtraForward = session.extraForwards.find(({ child }) => ( typeof child.exitCode === 'number' || childProcessDiagnostics.get(child)?.error )); if (stoppedExtraForward) { droppedReason = this.processErrorDetail( stoppedExtraForward.child, `Extra SSH forward ${stoppedExtraForward.id} exited`, ); } } if (!droppedReason) { if (session.mainForwardDetached) { // Fast path: cheap TCP probe before expensive SSH subprocess if (await isLocalTunnelReachable(session.localPort)) { // Tunnel alive — skip SSH check } else if (!await this.isControlMasterAlive(session.parsed, session.controlPath)) { droppedReason = 'SSH ControlMaster is not reachable'; } else { detachedNotice = 'Local tunnel unreachable but ControlMaster is alive'; } } } if (detachedNotice) { this.appendLogWithLevel(id, 'INFO', detachedNotice); } if (!droppedReason) { healthyTicks++; const pollMs = healthyTicks >= MONITOR_STABILIZE_TICKS ? MONITOR_STEADY_POLL_MS : MONITOR_INITIAL_POLL_MS; this.monitorTimers.set(id, setTimeout(tick, pollMs)); return; } this.appendLogWithLevel(id, 'WARN', droppedReason); await this.disconnectInternal(id, false); const attempt = this.nextRetryAttempt(id); if (attempt > DEFAULT_RECONNECT_MAX_ATTEMPTS) { this.setStatus(id, 'error', `${droppedReason}. Retry limit reached`, null, null, null, false, attempt, true); return; } this.setStatus(id, 'degraded', `${droppedReason}. Reconnecting`, null, null, null, false, attempt, false); const delayMs = Math.min((2 ** Math.max(attempt - 1, 0)) * 1000 + (nowMillis() % 700) + 100, 30000); await new Promise((resolve) => setTimeout(resolve, delayMs)); try { await this.connect(id); } catch (error) { this.setStatus(id, 'error', error instanceof Error ? error.message : String(error), null, null, null, false, attempt, true); } }; this.monitorTimers.set(id, setTimeout(tick, MONITOR_INITIAL_POLL_MS)); } async connect(id) { const trimmed = String(id || '').trim(); if (!trimmed || trimmed === LOCAL_HOST_ID) { throw new Error('SSH instance id is required'); } if (this.connecting.has(trimmed)) { this.appendLogWithLevel(trimmed, 'INFO', 'Connection already in progress'); return this.connecting.get(trimmed); } const instance = this.readInstances().instances.find((entry) => entry?.id === trimmed); if (!instance) { throw new Error('SSH instance not found'); } const retryAttempt = this.currentRetryAttempt(trimmed); const connectAttempt = this.nextConnectAttempt(trimmed); this.appendAttemptSeparator(trimmed, connectAttempt, retryAttempt); this.appendLog(trimmed, 'Starting SSH connection'); await this.disconnectInternal(trimmed, false); const task = this.connectBlocking(this.sanitizeInstance(instance)) .catch(async (error) => { this.setStatus(trimmed, 'error', error instanceof Error ? error.message : String(error), null, null, null, false, 0, true); await this.disconnectInternal(trimmed, false); throw error; }) .finally(() => { this.connecting.delete(trimmed); }); this.connecting.set(trimmed, task); return task; } async disconnect(id) { const trimmed = String(id || '').trim(); if (!trimmed || trimmed === LOCAL_HOST_ID) { throw new Error('SSH instance id is required'); } await this.disconnectInternal(trimmed, true); } async statusesWithDefaults(id) { if (id) { return [this.statusSnapshotForInstance(id)]; } return this.readInstances().instances .map((instance) => this.statusSnapshotForInstance(instance.id)) .sort((left, right) => left.id.localeCompare(right.id)); } async shutdownAll() { const ids = [...new Set([...this.sessions.keys(), ...this.connecting.keys(), ...this.monitorTimers.keys()])]; for (const id of ids) { await this.disconnectInternal(id, false); } } }