aps-agent/apps/desktop/sidecar.cjs

374 lines
12 KiB
JavaScript

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,
};