diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 861301371..acdeaf566 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -4,7 +4,7 @@ import { join } from "node:path"; import waitOn from "wait-on"; import { buildAppSnapHelper } from "./build-appsnap-helper.mjs"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); const devServerUrl = `http://localhost:${port}`; @@ -140,18 +140,18 @@ function startApp() { return; } - const app = spawn( - resolveElectronPath(), + const electronCommand = resolveElectronLaunchCommand( [`--synara-dev-root=${desktopDir}`, "dist-electron/main.js"], - { - cwd: desktopDir, - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, - stdio: "inherit", - }, + { development: true }, ); + const app = spawn(electronCommand.electronPath, electronCommand.args, { + cwd: desktopDir, + env: { + ...childEnv, + VITE_DEV_SERVER_URL: devServerUrl, + }, + stdio: "inherit", + }); currentApp = app; diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index e06f504a2..3c3075ba2 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -5,6 +5,7 @@ import { copyFileSync, cpSync, existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -146,3 +147,87 @@ export function resolveElectronPath() { return buildMacLauncher(electronBinaryPath); } + +export function isLinuxSetuidSandboxConfigured( + electronBinaryPath, + { platform = process.platform, lstat = lstatSync } = {}, +) { + if (platform !== "linux") { + return true; + } + + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + try { + const sandboxStat = lstat(sandboxPath); + return sandboxStat.isFile() && sandboxStat.uid === 0 && (sandboxStat.mode & 0o7777) === 0o4755; + } catch { + return false; + } +} + +export function isLinuxUserNamespaceSandboxAvailable({ + platform = process.platform, + runUnshare = spawnSync, +} = {}) { + if (platform !== "linux") { + return true; + } + + const result = runUnshare("unshare", ["-Ur", "true"], { + shell: false, + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + return !result.error && result.status === 0; +} + +export class LinuxSandboxConfigurationError extends Error { + constructor(sandboxPath) { + super( + `Electron needs either unprivileged user namespaces or a sandbox helper at ${sandboxPath} ` + + "that is a regular file owned by root with exact mode 4755. Enable one of those sandbox paths " + + "before launching Scient. For an isolated local development session only, " + + "set SCIENT_DEV_ALLOW_NO_SANDBOX=1 to accept the unsafe fallback explicitly.", + ); + this.name = "LinuxSandboxConfigurationError"; + this.sandboxPath = sandboxPath; + } +} + +export function resolveLinuxSandboxArgs( + electronBinaryPath, + { + platform = process.platform, + lstat = lstatSync, + runUnshare = spawnSync, + development = isDevelopment, + env = process.env, + warn = console.warn, + } = {}, +) { + if ( + isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat }) || + isLinuxUserNamespaceSandboxAvailable({ platform, runUnshare }) + ) { + return []; + } + + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + if (development && env.SCIENT_DEV_ALLOW_NO_SANDBOX === "1") { + warn( + "[desktop-launcher] Unsafe development override accepted: launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; + } + + throw new LinuxSandboxConfigurationError(sandboxPath); +} + +export function resolveElectronLaunchCommand(args = [], options = {}) { + const electronPath = resolveElectronPath(); + return { + electronPath, + args: [...resolveLinuxSandboxArgs(electronPath, options), ...args], + }; +} diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs new file mode 100644 index 000000000..b5d9e6b7c --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + isLinuxSetuidSandboxConfigured, + isLinuxUserNamespaceSandboxAvailable, + LinuxSandboxConfigurationError, + resolveLinuxSandboxArgs, +} from "./electron-launcher.mjs"; + +const ELECTRON_PATH = "/repo/node_modules/electron/dist/electron"; +const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox"; + +describe("Linux Electron sandbox launch policy", () => { + it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => { + const lstat = vi.fn(); + const runUnshare = vi.fn(); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat, runUnshare }), + ).toEqual([]); + expect(lstat).not.toHaveBeenCalled(); + expect(runUnshare).not.toHaveBeenCalled(); + }); + + it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => { + const lstat = vi.fn(() => ({ isFile: () => true, mode: 0o104755, uid: 0 })); + const runUnshare = vi.fn(); + + expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", lstat })).toBe(true); + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat, runUnshare }), + ).toEqual([]); + expect(lstat).toHaveBeenCalledWith(SANDBOX_PATH); + expect(runUnshare).not.toHaveBeenCalled(); + }); + + it("keeps Chromium's sandbox when unprivileged user namespaces work without a helper", () => { + const runUnshare = vi.fn(() => ({ status: 0 })); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => { + throw new Error("ENOENT"); + }, + runUnshare, + }), + ).toEqual([]); + expect(runUnshare).toHaveBeenCalledWith("unshare", ["-Ur", "true"], { + shell: false, + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + expect(isLinuxUserNamespaceSandboxAvailable({ platform: "linux", runUnshare })).toBe(true); + }); + + it.each([ + ["is not root-owned", { isFile: () => true, mode: 0o104755, uid: 1000 }], + ["is not setuid", { isFile: () => true, mode: 0o100755, uid: 0 }], + ["is group-writable", { isFile: () => true, mode: 0o104775, uid: 0 }], + ["has extra special bits", { isFile: () => true, mode: 0o106755, uid: 0 }], + ["is not a regular file", { isFile: () => false, mode: 0o104755, uid: 0 }], + ])("fails closed when chrome-sandbox %s", (_reason, metadata) => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => metadata, + runUnshare: () => ({ status: 1 }), + }), + ).toThrow(LinuxSandboxConfigurationError); + }); + + it("fails closed when both chrome-sandbox and unprivileged user namespaces are unavailable", () => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => { + throw new Error("ENOENT"); + }, + runUnshare: () => ({ status: 1 }), + }), + ).toThrow(expect.objectContaining({ sandboxPath: SANDBOX_PATH })); + }); + + it("allows --no-sandbox only through an explicit local-development override", () => { + const warn = vi.fn(); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + runUnshare: () => ({ status: 1 }), + development: true, + env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, + warn, + }), + ).toEqual(["--no-sandbox"]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Unsafe development override")); + }); + + it("refuses the unsafe override outside development", () => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + runUnshare: () => ({ status: 1 }), + development: false, + env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, + }), + ).toThrow(LinuxSandboxConfigurationError); + }); +}); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 677e33f2f..b864df9c5 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -2,14 +2,16 @@ import { spawn, spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); -const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); const mainJs = resolve(desktopDir, "dist-electron/main.js"); console.log("\nLaunching Electron smoke test..."); -const child = spawn(electronBin, [mainJs], { +const electronCommand = resolveElectronLaunchCommand([mainJs], { development: false }); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", env: { diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 345c3843a..2822a18c3 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { buildAppSnapHelper } from "./build-appsnap-helper.mjs"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; if (process.platform === "darwin") { buildAppSnapHelper({ arch: process.arch }); @@ -10,7 +10,10 @@ if (process.platform === "darwin") { const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"], { + development: true, +}); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, env: childEnv,