Pin the Node toolchain, remove runtime npm repair, make CI and E2E truthful
Template tests / tests (pull_request) Failing after 1m26s
Template tests / tests (pull_request) Failing after 1m26s
Phase 0 of the improvement plan (ai_prompts/prompt4.md): make the baseline reproducible and stop the test runner from masking real failures. - Pin Node >= 22.12 (engines + .nvmrc + engine-strict); every entry point fails fast with clear guidance instead of dying late with ERR_REQUIRE_ESM inside the packaging dependency graph. - electron-launcher.js is diagnostics-only: all runtime npm install/rebuild/ repair paths are removed. npm ci on the pinned toolchain is the only supported install path (README + GETTING_STARTED updated). - Refuse to silently launch unsandboxed on Linux: --no-sandbox now requires an explicit STEPFORGE_ALLOW_NO_SANDBOX/ELECTRON_DISABLE_SANDBOX opt-in and is otherwise a hard error with actionable fixes; user-namespace sandboxing is detected and preferred. - Click-capture E2E no longer converts startup crashes into "SKIPPED": the only allowed skip is the upfront absence of a display server. A missing shared library or crash now fails with the startup log. Same guard added to the startup smoke check. - GitHub CI: run on pull_request, pin Node from .nvmrc, drop the macOS matrix entry (not a support target), and audit production and full dependency trees as separate signals. Gitea CI: pull_request trigger + pinned Node. - Refresh package-lock on Node 22/npm 10 and remediate the form-data and undici advisories (npm audit: 0 vulnerabilities, prod and full tree). - Stop tracking generated machine-specific build reports (build/build_report.md, build/artifacts_manifest.json). Verified: 203 unit tests pass; repo-structure, startup-smoke, unit-workflows, sample-artifacts, and build-release checks pass locally with a real Electron launch. The click self-test now truthfully reports the pre-existing Linux arm/debounce capture failures (also red on Gitea CI main run 177) instead of hiding behind SKIPPED; that defect is scheduled for the capture-fix PR. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+93
-185
@@ -1,6 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const { spawnSync } = require('node:child_process');
|
||||
// Diagnostics-only Electron launcher helpers.
|
||||
//
|
||||
// This module never installs, rebuilds, or repairs dependencies at runtime.
|
||||
// The only supported dependency installation path is `npm ci` on the pinned
|
||||
// Node toolchain (see .nvmrc / package.json engines). A desktop launcher that
|
||||
// mutates node_modules silently drifts away from package-lock.json and can
|
||||
// download code at runtime; when the runtime is missing we fail with
|
||||
// actionable diagnostics instead.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
@@ -60,24 +68,83 @@ function sanitizeElectronEnv(baseEnv = process.env) {
|
||||
return env;
|
||||
}
|
||||
|
||||
// True only when the caller has explicitly marked this as a development or
|
||||
// CI environment where launching without the Chromium sandbox is acceptable.
|
||||
function noSandboxExplicitlyAllowed(env = process.env) {
|
||||
return env.STEPFORGE_ALLOW_NO_SANDBOX === '1' || env.ELECTRON_DISABLE_SANDBOX === '1';
|
||||
}
|
||||
|
||||
function sandboxHelperUsable(electronPath, statSync = fs.statSync) {
|
||||
if (!electronPath) return false;
|
||||
const helperPath = path.join(path.dirname(electronPath), 'chrome-sandbox');
|
||||
try {
|
||||
const stat = statSync(helperPath);
|
||||
return stat.uid === 0 && Boolean(stat.mode & 0o4000);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Decide how to launch on Linux with respect to the Chromium sandbox.
|
||||
// { args: [] } sandbox is available, launch normally
|
||||
// { args: ['--no-sandbox'] } explicitly allowed dev/CI launch
|
||||
// throws sandbox unavailable and not explicitly
|
||||
// allowed: refuse to normalize an
|
||||
// unsandboxed launch, explain how to fix it
|
||||
function linuxSandboxLaunchArgs({
|
||||
electronPath,
|
||||
platform = process.platform,
|
||||
statSync = fs.statSync,
|
||||
env = process.env,
|
||||
userNamespaces = userNamespacesAvailable,
|
||||
} = {}) {
|
||||
if (platform !== 'linux') return [];
|
||||
if (!electronPath) return ['--no-sandbox'];
|
||||
|
||||
const helperPath = path.join(path.dirname(electronPath), 'chrome-sandbox');
|
||||
// Modern kernels with unprivileged user namespaces do not need the setuid
|
||||
// helper; Chromium falls back to the namespace sandbox on its own. The
|
||||
// setuid helper check below covers kernels where that is disabled.
|
||||
if (sandboxHelperUsable(electronPath, statSync)) return [];
|
||||
if (userNamespaces()) return [];
|
||||
|
||||
if (noSandboxExplicitlyAllowed(env)) return ['--no-sandbox'];
|
||||
|
||||
const helperPath = electronPath
|
||||
? path.join(path.dirname(electronPath), 'chrome-sandbox')
|
||||
: '<node_modules/electron/dist>/chrome-sandbox';
|
||||
throw new Error(
|
||||
[
|
||||
'The Chromium sandbox is not available on this system, and StepForge',
|
||||
'refuses to silently launch unsandboxed.',
|
||||
'',
|
||||
'Fix one of the following:',
|
||||
` 1. Make the setuid sandbox helper usable:`,
|
||||
` sudo chown root:root "${helperPath}"`,
|
||||
` sudo chmod 4755 "${helperPath}"`,
|
||||
' 2. Enable unprivileged user namespaces (kernel/sysctl dependent):',
|
||||
' sudo sysctl -w kernel.unprivileged_userns_clone=1',
|
||||
'',
|
||||
'For development or CI only, you may explicitly opt in to an',
|
||||
'unsandboxed launch with STEPFORGE_ALLOW_NO_SANDBOX=1.',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
function userNamespacesAvailable() {
|
||||
try {
|
||||
const stat = statSync(helperPath);
|
||||
const ownedByRoot = stat.uid === 0;
|
||||
const hasSetuid = Boolean(stat.mode & 0o4000);
|
||||
if (ownedByRoot && hasSetuid) return [];
|
||||
// Debian/Ubuntu specific knob; absent elsewhere (treated as enabled).
|
||||
const knob = '/proc/sys/kernel/unprivileged_userns_clone';
|
||||
if (fs.existsSync(knob)) {
|
||||
return fs.readFileSync(knob, 'utf8').trim() === '1';
|
||||
}
|
||||
// Ubuntu 23.10+ AppArmor restriction on unprivileged user namespaces.
|
||||
const apparmorKnob = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns';
|
||||
if (fs.existsSync(apparmorKnob)) {
|
||||
return fs.readFileSync(apparmorKnob, 'utf8').trim() === '0';
|
||||
}
|
||||
return fs.existsSync('/proc/self/ns/user');
|
||||
} catch {
|
||||
// Missing or unreadable helper: fall back to the unsandboxed launcher.
|
||||
return false;
|
||||
}
|
||||
return ['--no-sandbox'];
|
||||
}
|
||||
|
||||
function electronBinaryCandidates({ packageRoot, distDir, platform }) {
|
||||
@@ -95,118 +162,21 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
|
||||
return candidatePaths;
|
||||
}
|
||||
|
||||
function runNpmCommand({
|
||||
packageRoot,
|
||||
npmArgs,
|
||||
errorLabel,
|
||||
npmExecPath = process.env.npm_execpath || null,
|
||||
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
|
||||
}) {
|
||||
if (!npmExecPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = spawnSync(npmNodeExecPath, [npmExecPath, ...npmArgs], {
|
||||
cwd: packageRoot,
|
||||
env: sanitizeElectronEnv(),
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
throw new Error(`${errorLabel} was interrupted by ${result.signal}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${errorLabel} failed with exit code ${result.status ?? 1}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function runNpmRebuild({
|
||||
packageRoot,
|
||||
npmExecPath = process.env.npm_execpath || null,
|
||||
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
|
||||
}) {
|
||||
return runNpmCommand({
|
||||
packageRoot,
|
||||
npmArgs: ['rebuild', 'electron', '--force', '--foreground-scripts'],
|
||||
errorLabel: 'Electron rebuild',
|
||||
npmExecPath,
|
||||
npmNodeExecPath,
|
||||
});
|
||||
}
|
||||
|
||||
function runNpmInstall({
|
||||
packageRoot,
|
||||
npmExecPath = process.env.npm_execpath || null,
|
||||
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
|
||||
}) {
|
||||
return runNpmCommand({
|
||||
packageRoot,
|
||||
npmArgs: [
|
||||
'install',
|
||||
'--include=dev',
|
||||
'--ignore-scripts=false',
|
||||
'--foreground-scripts',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--package-lock=false',
|
||||
],
|
||||
errorLabel: 'Electron dependency install',
|
||||
npmExecPath,
|
||||
npmNodeExecPath,
|
||||
});
|
||||
}
|
||||
|
||||
function repairElectronInstall({
|
||||
packageRoot,
|
||||
}) {
|
||||
const installScript = path.join(packageRoot, 'install.js');
|
||||
if (!fs.existsSync(installScript)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [installScript], {
|
||||
cwd: packageRoot,
|
||||
env: sanitizeElectronEnv(),
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
throw new Error(`Electron repair was interrupted by ${result.signal}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Electron repair failed with exit code ${result.status ?? 1}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) {
|
||||
const tried = candidatePaths.map((candidate) => ` - ${candidate}`).join('\n');
|
||||
const tried = (candidatePaths || []).map((candidate) => ` - ${candidate}`).join('\n');
|
||||
return [
|
||||
'Electron could not be started because the desktop runtime is missing.',
|
||||
'',
|
||||
`Looked under: ${packageRoot}`,
|
||||
`Expected the binary in: ${distDir}`,
|
||||
`Looked under: ${packageRoot || '(electron package not installed)'}`,
|
||||
`Expected the binary in: ${distDir || '(unknown)'}`,
|
||||
'',
|
||||
'Try reinstalling dependencies from the repo root:',
|
||||
'StepForge never installs dependencies at runtime. Reinstall them from',
|
||||
'the repo root on the pinned Node toolchain (see .nvmrc):',
|
||||
'',
|
||||
' npm install',
|
||||
' npm rebuild electron --force --foreground-scripts',
|
||||
' make sure ELECTRON_SKIP_BINARY_DOWNLOAD is not set',
|
||||
' npm ci',
|
||||
'',
|
||||
'If that does not help, delete node_modules/electron and install again.',
|
||||
'Make sure ELECTRON_SKIP_BINARY_DOWNLOAD is not set while installing.',
|
||||
'If the problem persists, delete node_modules entirely and run npm ci again.',
|
||||
'',
|
||||
'Searched:',
|
||||
tried,
|
||||
@@ -219,99 +189,37 @@ function resolveElectronBinary({
|
||||
platform = process.platform,
|
||||
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null,
|
||||
} = {}) {
|
||||
const repairErrors = [];
|
||||
|
||||
function resolveCurrentPackageRoot() {
|
||||
if (packageRoot) return packageRoot;
|
||||
if (!packageRoot) {
|
||||
const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron');
|
||||
if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) {
|
||||
packageRoot = conventionalRoot;
|
||||
return packageRoot;
|
||||
}
|
||||
packageRoot = resolveElectronPackageRoot();
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
function tryRepair(label, repairFn) {
|
||||
try {
|
||||
if (!repairFn()) {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
repairErrors.push(`${label}: ${error && error.message ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPackageRoot = resolveCurrentPackageRoot();
|
||||
if (!currentPackageRoot && !overrideDistPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
|
||||
return electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform }).find((candidate) =>
|
||||
fs.existsSync(candidate)
|
||||
);
|
||||
}
|
||||
|
||||
let currentPackageRoot = resolveCurrentPackageRoot();
|
||||
if (!currentPackageRoot && !overrideDistPath) {
|
||||
const installed = tryRepair('Electron dependency install', () =>
|
||||
runNpmInstall({ packageRoot: projectRoot })
|
||||
);
|
||||
if (installed) {
|
||||
return installed;
|
||||
}
|
||||
|
||||
currentPackageRoot = resolveCurrentPackageRoot();
|
||||
}
|
||||
|
||||
if (!currentPackageRoot && !overrideDistPath) {
|
||||
if (!packageRoot && !overrideDistPath) {
|
||||
throw new Error(
|
||||
'Electron could not be started because node_modules/electron is not installed.\n\n' +
|
||||
'Run `npm install` from the repo root, then try `npm start` again.'
|
||||
'StepForge never installs dependencies at runtime. Run `npm ci` from the\n' +
|
||||
'repo root on the pinned Node toolchain (see .nvmrc), then try again.'
|
||||
);
|
||||
}
|
||||
|
||||
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
|
||||
let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform });
|
||||
let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
|
||||
const distDir = overrideDistPath || path.join(packageRoot, 'dist');
|
||||
const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform });
|
||||
const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const repairAttempts = [
|
||||
['Electron rebuild', () => runNpmRebuild({ packageRoot: currentPackageRoot })],
|
||||
['Electron install repair', () => repairElectronInstall({ packageRoot: currentPackageRoot })],
|
||||
['Electron dependency install', () => runNpmInstall({ packageRoot: projectRoot })],
|
||||
];
|
||||
|
||||
for (const [label, repairFn] of repairAttempts) {
|
||||
const repaired = tryRepair(label, repairFn);
|
||||
if (repaired) {
|
||||
return repaired;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
buildMissingElectronError({
|
||||
packageRoot: currentPackageRoot,
|
||||
distDir,
|
||||
candidatePaths,
|
||||
}) +
|
||||
(repairErrors.length
|
||||
? `\n\nAutomatic repair attempts failed:\n${repairErrors.map((error) => ` - ${error}`).join('\n')}`
|
||||
: '')
|
||||
);
|
||||
throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildMissingElectronError,
|
||||
electronBinaryCandidates,
|
||||
readElectronPathHint,
|
||||
repairElectronInstall,
|
||||
runNpmRebuild,
|
||||
runNpmInstall,
|
||||
sanitizeElectronEnv,
|
||||
noSandboxExplicitlyAllowed,
|
||||
linuxSandboxLaunchArgs,
|
||||
resolveElectronBinary,
|
||||
resolveElectronPackageRoot,
|
||||
|
||||
Reference in New Issue
Block a user