const { EventEmitter } = require('events'); const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const net = require('net'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); const LOOPBACK_HOST = '127.0.0.1'; const SIDECAR_NONCE_HEADER = 'x-aps-sidecar-nonce'; const CHILD_ENV_ALLOWLIST = new Set([ 'ALLUSERSPROFILE', 'APPDATA', 'COMMONPROGRAMFILES', 'COMMONPROGRAMFILES(X86)', 'COMSPEC', 'HOME', 'HOMEDRIVE', 'HOMEPATH', 'LANG', 'LOCALAPPDATA', 'NUMBER_OF_PROCESSORS', 'PATH', 'PATHEXT', 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'PROCESSOR_LEVEL', 'PROCESSOR_REVISION', 'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'SYSTEMDRIVE', 'SYSTEMROOT', 'TEMP', 'TMP', 'TZ', 'USERPROFILE', 'WINDIR', ]); function reserveLoopbackPort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.once('error', reject); server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => { const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; server.close((error) => { if (error) reject(error); else if (!port) reject(new Error('Failed to reserve a loopback port')); else resolve(port); }); }); }); } function bundledSidecarPath(resourcesPath, platform = process.platform) { const name = platform === 'win32' ? 'aps-sidecar.exe' : 'aps-sidecar'; const executable = path.join(resourcesPath, 'sidecar', name); if (!fs.existsSync(executable)) { throw new Error(`Bundled APS sidecar is missing: ${executable}`); } return executable; } function probeHealth(url, options = {}) { const timeoutMs = options.timeoutMs ?? 1_500; const expectedNonce = options.expectedNonce ?? null; return new Promise((resolve, reject) => { const headers = expectedNonce ? { [SIDECAR_NONCE_HEADER]: expectedNonce } : {}; const request = http.get(url, { timeout: timeoutMs, headers }, (response) => { response.resume(); if (response.statusCode !== 200) { reject(new Error(`Health probe returned HTTP ${response.statusCode}`)); } else if (expectedNonce && response.headers['x-aps-sidecar-nonce'] !== expectedNonce) { reject(new Error('Health probe returned an unexpected sidecar identity')); } else { resolve(); } }); request.once('timeout', () => request.destroy(new Error('Health probe timed out'))); request.once('error', reject); }); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForHealth(url, options = {}) { const timeoutMs = options.timeoutMs ?? 45_000; const intervalMs = options.intervalMs ?? 250; const probe = options.probe ?? probeHealth; const expectedNonce = options.expectedNonce ?? null; const deadline = Date.now() + timeoutMs; let lastError = null; while (Date.now() < deadline) { try { await probe(url, { expectedNonce }); return; } catch (error) { lastError = error; await delay(intervalMs); } } const detail = lastError instanceof Error ? `: ${lastError.message}` : ''; throw new Error(`APS sidecar did not become healthy within ${timeoutMs}ms${detail}`); } const BLOCKED_PYTHON_ENV = new Set(['PYTHONHOME', 'PYTHONPATH', 'PYTHONUSERBASE']); const CONDA_DISTRIBUTION_SEGMENT = /^(?:anaconda|miniconda|miniforge|mambaforge)(?:\d.*)?$/i; function normalizedPathEntry(value, platform) { const pathApi = platform === 'win32' ? path.win32 : path.posix; const unquoted = String(value).trim().replace(/^"(.*)"$/, '$1'); const normalized = pathApi.normalize(unquoted).replace(/[\\/]+$/, ''); return platform === 'win32' ? normalized.toLowerCase() : normalized; } function condaPrefixesFromEnv(env, platform) { const prefixes = []; for (const [name, value] of Object.entries(env)) { if (name.toUpperCase().startsWith('CONDA_PREFIX') && value) { prefixes.push(normalizedPathEntry(value, platform)); } } return prefixes; } function isPathWithin(candidate, prefix, platform) { if (!candidate || !prefix) return false; if (candidate === prefix) return true; const separator = platform === 'win32' ? '\\' : '/'; return candidate.startsWith(`${prefix}${separator}`); } function isCondaPathEntry(entry, prefixes, platform) { const comparable = normalizedPathEntry(entry, platform); if (prefixes.some((prefix) => isPathWithin(comparable, prefix, platform))) return true; const segments = comparable.split(/[\\/]+/).filter(Boolean); return segments.some( (segment) => CONDA_DISTRIBUTION_SEGMENT.test(segment) || segment.toLowerCase() === 'condabin', ); } function sanitizePathValue(value, sourceEnv, platform) { const delimiter = platform === 'win32' ? ';' : ':'; const prefixes = condaPrefixesFromEnv(sourceEnv, platform); return String(value) .split(delimiter) .map((entry) => entry.trim().replace(/^"(.*)"$/, '$1')) .filter((entry) => entry && !isCondaPathEntry(entry, prefixes, platform)) .join(delimiter); } function buildChildEnv(baseEnv, overrides, platform = process.platform) { const env = {}; for (const [name, value] of Object.entries(baseEnv)) { const upperName = name.toUpperCase(); if (CHILD_ENV_ALLOWLIST.has(upperName) || upperName.startsWith('LC_')) { env[name] = value; } } const result = { ...env, ...overrides }; const sourceEnv = { ...baseEnv, ...overrides }; for (const name of Object.keys(result)) { const upperName = name.toUpperCase(); if (upperName.startsWith('CONDA_') || BLOCKED_PYTHON_ENV.has(upperName)) { delete result[name]; } else if (upperName === 'PATH') { result[name] = sanitizePathValue(result[name], sourceEnv, platform); } } result.PYTHONNOUSERSITE = '1'; result.PYTHONUTF8 = '1'; return result; } function terminateProcessTree(child, platform = process.platform, runner = spawnSync) { if (platform === 'win32' && Number.isInteger(child?.pid) && child.pid > 0) { const completed = runner( 'taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }, ); if (!completed.error && completed.status === 0) return; } try { child?.kill(); } catch { /* best-effort shutdown */ } } class SidecarManager extends EventEmitter { constructor(options) { super(); this.resourcesPath = options.resourcesPath; this.apsHome = options.apsHome; this.platform = options.platform ?? process.platform; this.spawnProcess = options.spawnProcess ?? spawn; this.baseEnv = options.baseEnv ?? process.env; this.terminateChild = options.terminateChild ?? ((child) => terminateProcessTree(child, this.platform)); this.getPort = options.getPort ?? reserveLoopbackPort; this.waitForHealthy = options.waitForHealthy ?? waitForHealth; this.startupTimeoutMs = options.startupTimeoutMs ?? 45_000; this.restartLimit = options.restartLimit ?? 3; this.restartDelayMs = options.restartDelayMs ?? 1_000; this.stabilityResetMs = options.stabilityResetMs ?? 60_000; this.createNonce = options.createNonce ?? (() => crypto.randomBytes(32).toString('hex')); this.createAuditToken = options.createAuditToken ?? (() => crypto.randomBytes(32).toString('hex')); this.appVersion = options.appVersion ?? null; this.nonce = null; this.auditToken = null; this.child = null; this.port = null; this.origin = null; this.generation = 0; this.restartCount = 0; this.restartTimer = null; this.stabilityTimer = null; this.stopping = false; } async start() { if (this.child) return this.origin; this.stopping = false; await this._launch(); return this.origin; } _writeLog(streamName, chunk) { const logDir = path.join(this.apsHome, 'logs'); fs.mkdirSync(logDir, { recursive: true }); const logPath = path.join(logDir, 'sidecar.log'); if (fs.existsSync(logPath) && fs.statSync(logPath).size >= 10 * 1024 * 1024) { const previousPath = `${logPath}.1`; fs.rmSync(previousPath, { force: true }); fs.renameSync(logPath, previousPath); } const text = String(chunk).slice(0, 64 * 1024); const line = `[${new Date().toISOString()}] [${streamName}] ${text}`; fs.appendFileSync(logPath, line, 'utf8'); } async _launch() { const executable = bundledSidecarPath(this.resourcesPath, this.platform); const uiDir = path.join(this.resourcesPath, 'web', 'dist'); if (!fs.existsSync(path.join(uiDir, 'index.html'))) { throw new Error(`Bundled APS web UI is missing: ${uiDir}`); } const port = await this.getPort(); const nonce = this.createNonce(); const auditToken = this.createAuditToken(); this.port = port; this.nonce = nonce; this.auditToken = auditToken; this.origin = `http://${LOOPBACK_HOST}:${port}`; const generation = ++this.generation; let startupSettled = false; let rejectEarlyExit; const earlyExit = new Promise((_, reject) => { rejectEarlyExit = reject; }); const child = this.spawnProcess(executable, [], { cwd: path.dirname(executable), env: buildChildEnv(this.baseEnv, { APS_MODE: 'desktop', APS_HOME: this.apsHome, APS_API_HOST: LOOPBACK_HOST, APS_API_PORT: String(port), APS_UI_DIR: uiDir, APS_SIDECAR_NONCE: nonce, ...(this.appVersion ? { APS_APP_VERSION: this.appVersion } : {}), APS_PARENT_PID: String(process.pid), }), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); this.child = child; child.stdout?.on('data', (chunk) => this._writeLog('stdout', chunk)); child.stderr?.on('data', (chunk) => this._writeLog('stderr', chunk)); child.once('error', (error) => { this._writeLog('launcher', `${error.stack ?? error}\n`); if (!startupSettled) rejectEarlyExit(error); }); child.once('exit', (code, signal) => { if (generation !== this.generation) return; this.child = null; const error = new Error(`APS sidecar exited code=${code} signal=${signal}`); this._writeLog('launcher', `${error.message}\n`); if (!startupSettled) rejectEarlyExit(error); else if (!this.stopping) this._scheduleRestart(error); }); try { await Promise.race([ this.waitForHealthy(`${this.origin}/api/health`, { timeoutMs: this.startupTimeoutMs, expectedNonce: nonce, }), earlyExit, ]); startupSettled = true; this._armStabilityReset(); this.emit('ready', { origin: this.origin, pid: child.pid }); } catch (error) { startupSettled = true; if (this.child === child) { this.terminateChild(child); this.child = null; } throw error; } } _armStabilityReset() { clearTimeout(this.stabilityTimer); this.stabilityTimer = setTimeout(() => { this.restartCount = 0; }, this.stabilityResetMs); this.stabilityTimer.unref?.(); } _scheduleRestart(cause) { clearTimeout(this.stabilityTimer); const previousOrigin = this.origin; this.port = null; this.origin = null; this.nonce = null; this.auditToken = null; if (this.restartCount >= this.restartLimit) { this.emit('fatal', cause); return; } this.restartCount += 1; const attempt = this.restartCount; this.emit('restarting', { attempt, cause, previousOrigin }); this.restartTimer = setTimeout(async () => { this.restartTimer = null; if (this.stopping) return; try { await this._launch(); this.emit('restarted', { attempt, origin: this.origin }); } catch (error) { this._writeLog('launcher', `restart ${attempt} failed: ${error.stack ?? error}\n`); this._scheduleRestart(error); } }, this.restartDelayMs * attempt); } stop() { this.stopping = true; clearTimeout(this.restartTimer); clearTimeout(this.stabilityTimer); this.restartTimer = null; this.stabilityTimer = null; this.generation += 1; const child = this.child; this.child = null; if (child) this.terminateChild(child); } } module.exports = { LOOPBACK_HOST, SidecarManager, buildChildEnv, bundledSidecarPath, probeHealth, reserveLoopbackPort, terminateProcessTree, waitForHealth, };