diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a2e293848..b735b2b06c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,7 +178,7 @@ jobs: name: desktop-tests (ubuntu-latest) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read @@ -201,7 +201,7 @@ jobs: - name: Install virtual display if: ${{ runner.os == 'Linux' }} - run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm xdotool - name: Install Playwright browser if: ${{ runner.os == 'Linux' }} @@ -249,17 +249,50 @@ jobs: if-no-files-found: ignore retention-days: 7 - - name: Build and smoke unpacked desktop app + - name: Build Linux desktop artifacts if: ${{ runner.os == 'Linux' }} - run: npm run build:desktop -- --publish never --linux --x64 --dir + run: npm run build:desktop -- --publish never --linux --x64 env: EP_GH_IGNORE_TIME: true - PASEO_DESKTOP_SMOKE: "1" PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke + - name: Smoke installed artifacts with restricted user namespaces + if: ${{ runner.os == 'Linux' }} + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + if unshare --user --map-root-user --net true; then + echo "Expected the launching user's namespace probe to be denied" + exit 1 + fi + sudo apt-get install -y ./packages/desktop/release/*.deb + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted + + - name: Smoke sandbox-preserving portable launches + if: ${{ runner.os == 'Linux' }} + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + unshare --user --map-root-user --net true + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns + + - name: Smoke installed RPM with restricted user namespaces + if: ${{ runner.os == 'Linux' }} + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + sudo dpkg --remove paseo + # Ubuntu supplies the runtime libraries under Debian package names. + # RPM still performs the real payload installation and postinstall. + sudo rpm --install --nodeps packages/desktop/release/*.rpm + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm + - name: Upload packaged smoke diagnostics uses: actions/upload-artifact@v4 - if: ${{ failure() && runner.os == 'Linux' }} + if: ${{ !cancelled() && runner.os == 'Linux' }} with: name: desktop-packaged-smoke-linux-x64 path: ${{ runner.temp }}/desktop-smoke diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 5c9bc567ba..c21de2ab4e 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -261,7 +261,7 @@ jobs: NODE - name: Install Linux smoke display - run: sudo apt-get update && sudo apt-get install -y xvfb xauth + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm - name: Build desktop release shell: bash @@ -278,7 +278,7 @@ jobs: npm run build:desktop "${build_args[@]}" - name: Upload packaged smoke diagnostics - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: desktop-smoke-linux-x64 diff --git a/docs/release.md b/docs/release.md index e66b8e6fbb..26af0182eb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -192,6 +192,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z - Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores. - `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag - Desktop assets now come from the Electron package at `packages/desktop` +- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together. - Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published. - **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section. diff --git a/docs/testing.md b/docs/testing.md index 9b94de5038..933dcba740 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -107,14 +107,18 @@ When a test is labeled end-to-end, it calls the real service. No environment var The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself. -The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes: +The harness launches the packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes: - the `paseo://app/` renderer mounts into `#root`; - the sandboxed preload exposes the desktop bridge; - the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap; - the bundled CLI can query that daemon and run a terminal command. -Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure. +Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff selects desktop coverage. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 in PR CI. + +Never repair `chrome-sandbox` in the smoke harness. The old unpacked smoke set its mode to 4755 and concealed a broken package installer. Run installer tests as root and launch tests as an ordinary user: a root-run namespace probe does not reproduce Ubuntu's AppArmor policy for desktop users. Preserve both restricted and unrestricted cases; either one alone permits another sandbox regression. + +Smoke jobs upload renderer screenshots and desktop/daemon diagnostics, including successful Linux sandbox evidence. To exercise the smoke locally on Linux: diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index cc7574dc21..87cc058a0c 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -335,3 +335,20 @@ export function readLegacySkillSelection(): Promise export async function deleteLegacySkillSelection(): Promise { await invokeDesktopCommand("delete_legacy_skill_selection"); } + +export interface DesktopSandboxDiagnostics { + enabled: boolean; + reason: string; +} + +export async function getDesktopSandboxDiagnostics(): Promise { + const result: unknown = await invokeDesktopCommand("desktop_sandbox_diagnostics"); + if ( + !isRecord(result) || + typeof result.enabled !== "boolean" || + typeof result.reason !== "string" + ) { + throw new Error("Unexpected desktop sandbox diagnostics response."); + } + return { enabled: result.enabled, reason: result.reason }; +} diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts index 34c4482a4c..295344458f 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts @@ -6,6 +6,7 @@ import { function makeSources(): DesktopDiagnosticSources { return { + getSandboxDiagnostics: async () => ({ enabled: true, reason: "user namespaces available" }), getStatus: async () => ({ serverId: "server-1", status: "running", @@ -147,3 +148,20 @@ describe("desktop diagnostic report", () => { expect(report).toContain("Desktop app log tail\n Error: app log unavailable"); }); }); + +test("reports sandbox degradation and its reason even if daemon diagnostics fail", async () => { + const result = await collectDesktopDiagnosticSections({ + ...makeSources(), + getStatus: async () => { + throw new Error("daemon unavailable"); + }, + getSandboxDiagnostics: async () => ({ + enabled: false, + reason: "user namespaces unavailable; no usable SUID helper", + }), + }); + expect(result.sections.join("\n")).toContain( + "Chromium sandbox\n State: Disabled\n Reason: user namespaces unavailable; no usable SUID helper", + ); + expect(result.status).toBe("failed"); +}); diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.ts index 531ae11fbb..22eb7e388f 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.ts @@ -1,4 +1,6 @@ import { + getDesktopSandboxDiagnostics, + type DesktopSandboxDiagnostics, getDesktopAppLogs, getDesktopDaemonLogs, getDesktopDaemonStatus, @@ -19,6 +21,7 @@ export interface DesktopDiagnosticCollectionResult { } export interface DesktopDiagnosticSources { + getSandboxDiagnostics: () => Promise; getStatus: () => Promise; getDaemonLogs: () => Promise; getAppLogs: () => Promise; @@ -26,6 +29,7 @@ export interface DesktopDiagnosticSources { } const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = { + getSandboxDiagnostics: getDesktopSandboxDiagnostics, getStatus: getDesktopDaemonStatus, getDaemonLogs: getDesktopDaemonLogs, getAppLogs: getDesktopAppLogs, @@ -38,12 +42,33 @@ export async function collectDesktopDiagnosticSections( const sections: string[] = []; let failed = false; - const [daemonResult, appLogsResult, updaterResult] = await Promise.allSettled([ + const [daemonResult, appLogsResult, updaterResult, sandboxResult] = await Promise.allSettled([ Promise.all([sources.getStatus(), sources.getDaemonLogs()]), sources.getAppLogs(), sources.getUpdaterDiagnostics(), + sources.getSandboxDiagnostics(), ]); + if (sandboxResult.status === "fulfilled") { + const sandbox = sandboxResult.value; + sections.push( + formatDiagnosticSection("Chromium sandbox", [ + { + label: "State", + value: sandbox.enabled ? "Enabled" : "Disabled", + }, + { label: "Reason", value: sandbox.reason }, + ]), + ); + } else { + failed = true; + sections.push( + formatDiagnosticSection("Chromium sandbox", [ + { label: "Error", value: toMessage(sandboxResult.reason) }, + ]), + ); + } + if (daemonResult.status === "fulfilled") { const [status, daemonLogs] = daemonResult.value; const appLogs = appLogsResult.status === "fulfilled" ? appLogsResult.value : null; diff --git a/packages/desktop/e2e/linux-artifact-smoke.js b/packages/desktop/e2e/linux-artifact-smoke.js new file mode 100644 index 0000000000..5a81bf07de --- /dev/null +++ b/packages/desktop/e2e/linux-artifact-smoke.js @@ -0,0 +1,61 @@ +// Observe published Linux artifacts without repairing their sandbox permissions. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { smokePackagedDesktopApp } = require("./packaged-app-smoke.js"); + +async function main() { + if (process.getuid() === 0) throw new Error("Launch the smoke as an unprivileged user"); + const release = path.resolve(process.argv[2]); + const portableSandbox = process.argv[3] === "enabled"; + const artifactRoot = process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR; + const installedOnly = process.argv.includes("--installed-only"); + const extracted = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-linux-artifacts-")); + const findArtifact = (suffix) => { + const matches = fs.readdirSync(release).filter((file) => file.endsWith(suffix)); + if (matches.length !== 1) throw new Error(`Expected one ${suffix} in ${release}: ${matches}`); + return path.join(release, matches[0]); + }; + try { + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "installed"); + const helper = fs.statSync("/opt/Paseo/chrome-sandbox"); + if (helper.uid !== 0 || (helper.mode & 0o7777) !== 0o4755) { + throw new Error("Installed native package did not provide a root-owned 4755 helper"); + } + await smokePackagedDesktopApp({ appPath: "/opt/Paseo", expectedSandbox: true }); + if (installedOnly) return; + + const appImage = findArtifact(".AppImage"); + fs.chmodSync(appImage, 0o755); + execFileSync(appImage, ["--appimage-extract"], { cwd: extracted, stdio: "ignore" }); + const appDir = path.join(extracted, "squashfs-root"); + const desktopEntry = fs.readFileSync(path.join(appDir, "Paseo.desktop"), "utf8"); + if (/^Exec=.*--no-sandbox/m.test(desktopEntry)) { + throw new Error("AppImage desktop entry bypasses runtime sandbox policy"); + } + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "appimage"); + await smokePackagedDesktopApp({ + appPath: appDir, + executablePath: appImage, + launchArgs: ["--appimage-extract-and-run"], + expectedSandbox: portableSandbox, + }); + + const tarDir = path.join(extracted, "tar"); + fs.mkdirSync(tarDir); + execFileSync("tar", ["-xzf", findArtifact(".tar.gz"), "-C", tarDir]); + const appPath = fs.existsSync(path.join(tarDir, "Paseo")) + ? tarDir + : path.join(tarDir, fs.readdirSync(tarDir)[0]); + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "tar"); + await smokePackagedDesktopApp({ appPath, expectedSandbox: portableSandbox }); + } finally { + fs.rmSync(extracted, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 8b121a1e7f..f3c5a30cda 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -68,52 +68,17 @@ function getMacMainExecutablePath(appPath) { return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME); } -function ensureLinuxSandboxPermissions(appPath) { - if (process.platform !== "linux") { - return; - } - - const sandboxPath = path.join(appPath, "chrome-sandbox"); - if (!fs.existsSync(sandboxPath)) { - throw new Error(`Chromium sandbox helper does not exist: ${sandboxPath}`); - } - - const hasRequiredPermissions = () => { - const stat = fs.statSync(sandboxPath); - return stat.uid === 0 && (stat.mode & 0o7777) === 0o4755; - }; - if (hasRequiredPermissions()) { - return; - } - - const chown = spawnSync("sudo", ["-n", "chown", "root:root", sandboxPath], { - encoding: "utf8", - }); - const chmod = - chown.status === 0 - ? spawnSync("sudo", ["-n", "chmod", "4755", sandboxPath], { encoding: "utf8" }) - : null; - if (chown.error || chown.status !== 0 || chmod?.error || chmod?.status !== 0) { - throw new Error( - `Failed to configure Chromium sandbox helper ${sandboxPath}. Run: sudo chown root:root ${sandboxPath} && sudo chmod 4755 ${sandboxPath}.\n${chown.stderr?.trim() || chmod?.stderr?.trim() || chown.error || chmod?.error || "Permissions remained incorrect."}`, - ); - } - if (!hasRequiredPermissions()) { - throw new Error(`Chromium sandbox helper permissions remained incorrect: ${sandboxPath}`); - } -} - -function getLaunchCommand(executablePath) { +function getLaunchCommand(executablePath, args) { if (process.platform !== "linux") { return { command: executablePath, - args: [], + args, }; } return { command: "xvfb-run", - args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath], + args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath, ...args], }; } @@ -312,7 +277,7 @@ function formatLogs({ stdout, stderr, userData, daemonHome }) { ].join("\n\n"); } -async function writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }) { +async function writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome, error }) { const artifactDir = process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR?.trim(); if (!artifactDir) { return; @@ -320,13 +285,15 @@ async function writeFailureArtifacts({ page, stdout, stderr, userData, daemonHom fs.mkdirSync(artifactDir, { recursive: true }); fs.writeFileSync( - path.join(artifactDir, "failure.txt"), - `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n\n${formatLogs({ - stdout, - stderr, - userData, - daemonHome, - })}\n`, + path.join(artifactDir, error ? "failure.txt" : "success.txt"), + `${error instanceof Error ? (error.stack ?? error.message) : String(error ?? "Packaged desktop smoke passed")}\n\n${formatLogs( + { + stdout, + stderr, + userData, + daemonHome, + }, + )}\n`, ); const desktopLog = readIfExists(path.join(userData, "logs", "main.log")); @@ -793,6 +760,66 @@ async function stopCliDaemon({ appPath, env }) { }); } +async function openSmokeWorkspace({ appPath, env, page, daemonHome }) { + const projectPath = path.join(daemonHome, "sandbox-smoke-project"); + fs.mkdirSync(projectPath); + fs.writeFileSync(path.join(projectPath, "README.md"), "Packaged Linux sandbox smoke\n"); + const workspace = await runCliShimJsonCommand({ + appPath, + env, + args: [ + "workspace", + "create", + "--isolation", + "local", + "--path", + projectPath, + "--title", + "Sandbox smoke workspace", + ], + label: "Create packaged smoke workspace", + }); + await page.getByRole("button", { name: "Sandbox smoke workspace", exact: true }).click(); + await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspace.workspaceId}`)); + await page.getByRole("textbox", { name: "Message agent..." }).click(); + console.log(`Packaged desktop smoke: renderer opened workspace ${workspace.workspaceId}`); +} + +async function assertSandboxState({ browser, page, expectedSandbox, stdout, stderr }) { + const diagnostics = await page.evaluate(() => + window.paseoDesktop.invoke("desktop_sandbox_diagnostics"), + ); + if (diagnostics.enabled !== expectedSandbox) { + throw new Error( + `Unexpected sandbox decision: ${JSON.stringify(diagnostics)}; expected enabled=${expectedSandbox}`, + ); + } + if (!stdout.join("").includes("[desktop] app startup")) { + throw new Error("Missing production app startup log"); + } + const session = await browser.newBrowserCDPSession(); + const { processInfo } = await session.send("SystemInfo.getProcessInfo"); + await session.detach(); + const renderers = processInfo.filter((info) => info.type === "renderer"); + if (renderers.length === 0) throw new Error("No renderer process found"); + for (const renderer of renderers) { + const status = fs.readFileSync(`/proc/${renderer.id}/status`, "utf8"); + const security = status + .split("\n") + .filter((line) => /^(NoNewPrivs|Seccomp|Uid|Gid):/.test(line)) + .join("; "); + const report = `Sandbox evidence: enabled=${expectedSandbox}; ${diagnostics.reason}; renderer ${renderer.id}: ${security}`; + console.log(report); + stdout.push(`${report}\n`); + if ( + expectedSandbox && + (!/^NoNewPrivs:\s+1$/m.test(status) || !/^Seccomp:\s+2$/m.test(status)) + ) { + throw new Error(`Renderer lacks OS sandbox: ${report}\n${stderr.join("")}`); + } + } +} + function assertLinuxDesktopIdentity(appPath) { if (process.platform === "linux") { const metadata = JSON.parse( @@ -806,11 +833,14 @@ function assertLinuxDesktopIdentity(appPath) { } } -async function smokePackagedDesktopApp({ appPath }) { - const executablePath = getExecutablePath(appPath); +async function smokePackagedDesktopApp({ + appPath, + executablePath = getExecutablePath(appPath), + launchArgs = [], + expectedSandbox, +}) { assertExecutable(executablePath, "Packaged app executable"); assertLinuxDesktopIdentity(appPath); - ensureLinuxSandboxPermissions(appPath); await smokeColdCliDaemonStart({ appPath }); const userData = createTempDir("paseo-smoke-user-data-"); @@ -834,7 +864,7 @@ async function smokePackagedDesktopApp({ appPath }) { const stdout = []; const stderr = []; - const launch = getLaunchCommand(executablePath); + const launch = getLaunchCommand(executablePath, launchArgs); console.log(`Packaged desktop smoke: launching ${launch.command} ${launch.args.join(" ")}`); const child = spawn(launch.command, launch.args, { detached: process.platform !== "win32", @@ -886,12 +916,17 @@ async function smokePackagedDesktopApp({ appPath }) { console.log("Packaged desktop smoke: renderer-started desktop daemon reported running"); await smokeCliShim({ appPath, env }); await smokeCliTerminal({ appPath, env }); + if (expectedSandbox !== undefined) { + await assertSandboxState({ browser, page, expectedSandbox, stdout, stderr }); + await openSmokeWorkspace({ appPath, env, page, daemonHome }); + } + await writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome }); await stopDaemonForCleanup(); console.log( `Packaged desktop smoke passed: real renderer and preload loaded; renderer-started desktop daemon pid ${status.pid}, listen ${status.listen}; CLI shim daemon status and terminal smoke succeeded`, ); } catch (error) { - await writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch( + await writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch( (artifactError) => { console.warn(`Packaged desktop smoke: failed to write failure artifacts: ${artifactError}`); }, diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 5fa62ef91a..fb01c7b194 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -66,7 +66,13 @@ linux: - deb - rpm - tar.gz +deb: + afterInstall: scripts/linux-sandbox/after-install.tpl +rpm: + afterInstall: scripts/linux-sandbox/after-install.tpl appImage: + # A nonempty argument list replaces builder's hardcoded --no-sandbox default. + executableArgs: ["--class=Paseo"] # Keep the AppImage filename stable across versions (no ${version}) so # electron-updater overwrites the file in place instead of renaming it to a new # versioned path. A rename orphans the old binary, breaks desktop shortcuts, and diff --git a/packages/desktop/scripts/after-pack.js b/packages/desktop/scripts/after-pack.js index e5222d68fe..9826a6a1e9 100644 --- a/packages/desktop/scripts/after-pack.js +++ b/packages/desktop/scripts/after-pack.js @@ -3,6 +3,8 @@ const path = require("path"); const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js"); +const { installLinuxLauncher } = require("./linux-sandbox"); + const EXECUTABLE_NAME = "Paseo"; // electron-builder arch enum → Node.js arch string @@ -115,6 +117,10 @@ exports.default = async function afterPack(context) { pruneNativeModules(context.appOutDir, platform, arch); + if (platform === "linux") { + installLinuxLauncher(context.appOutDir); + } + if (platform === "linux" || platform === "win32") { if (arch !== process.arch) { console.log( diff --git a/packages/desktop/scripts/linux-sandbox/after-install.tpl b/packages/desktop/scripts/linux-sandbox/after-install.tpl new file mode 100644 index 0000000000..5723ea065b --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/after-install.tpl @@ -0,0 +1,54 @@ +#!/bin/bash + +if type update-alternatives >/dev/null 2>&1; then + # Remove previous link if it doesn't use update-alternatives + if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then + rm -f '/usr/bin/${executable}' + fi + update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +else + ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +fi + +# Package installation runs as root, whose namespace access says nothing about +# the desktop user under AppArmor. Always provide Chromium's SUID alternative. +chown root:root '/opt/${sanitizedProductName}/chrome-sandbox' || exit 1 +chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || exit 1 + +if hash update-mime-database 2>/dev/null; then + update-mime-database /usr/share/mime || true +fi + +if hash update-desktop-database 2>/dev/null; then + update-desktop-database /usr/share/applications || true +fi + +# Install apparmor profile. (Ubuntu 24+) +# First check if the version of AppArmor running on the device supports our profile. +# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0. +# In that case, we just skip installing the profile since the app runs fine without it on 22.04. +# +# Those apparmor_parser flags are akin to performing a dry run of loading a profile. +# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles +# +# Unfortunately, at the moment AppArmor doesn't have a good story for backwards compatibility. +# https://askubuntu.com/questions/1517272/writing-a-backwards-compatible-apparmor-profile +if apparmor_status --enabled > /dev/null 2>&1; then + APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile' + APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}' + if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then + cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET" + + # Updating the current AppArmor profile is not possible and probably not meaningful in a chroot'ed environment. + # Use cases are for example environments where images for clients are maintained. + # There, AppArmor might correctly be installed, but live updating makes no sense. + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + # Extra flags taken from dh_apparmor: + # > By using '-W -T' we ensure that any abstraction updates are also pulled in. + # https://wiki.debian.org/AppArmor/Contribute/FirstTimeProfileImport + apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET" + fi + else + echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile" + fi +fi diff --git a/packages/desktop/scripts/linux-sandbox/index.js b/packages/desktop/scripts/linux-sandbox/index.js new file mode 100644 index 0000000000..f641e721c2 --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/index.js @@ -0,0 +1,12 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +// Keep one pre-Chromium entrypoint for AppRun, desktop entries, updates, and tarballs. +exports.installLinuxLauncher = function installLinuxLauncher(appOutDir) { + const launcher = path.join(appOutDir, "Paseo"); + if (!fs.existsSync(`${launcher}.bin`)) { + fs.renameSync(launcher, `${launcher}.bin`); + } + fs.copyFileSync(path.join(__dirname, "launcher.sh"), launcher); + fs.chmodSync(launcher, 0o755); +}; diff --git a/packages/desktop/scripts/linux-sandbox/launcher.sh b/packages/desktop/scripts/linux-sandbox/launcher.sh new file mode 100644 index 0000000000..8f0b6a609f --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/launcher.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +# Resolve /usr/bin alternatives and user-created symlinks before finding Electron. +launcher=$(readlink -f -- "$0") +executable="${launcher}.bin" + +# Node entrypoints are not Chromium processes. Never inject browser flags here. +if [ "${ELECTRON_RUN_AS_NODE:-}" = 1 ]; then + exec "$executable" "$@" +fi + +for arg in "$@"; do + if [ "$arg" = '--no-sandbox' ]; then + export PASEO_DESKTOP_SANDBOX_REASON='requested by --no-sandbox' + printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" + fi +done + +# Match main's whitespace-separated debugging flags without evaluating shell code. +set -f +for flag in ${PASEO_ELECTRON_FLAGS:-}; do + if [ "$flag" = '--no-sandbox' ]; then + export PASEO_DESKTOP_SANDBOX_REASON='requested by PASEO_ELECTRON_FLAGS' + printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" --no-sandbox "$@" + fi +done + +# Map the real UID and create a network namespace as Chromium does. A bare +# unshare --user can succeed under AppArmor while namespace capabilities fail. +if namespace_error=$(unshare --user --map-root-user --net true 2>&1); then + export PASEO_DESKTOP_SANDBOX_REASON='user namespaces available' + printf '[linux-sandbox] enabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" +fi + +helper="$(dirname -- "$launcher")/chrome-sandbox" +if [ -x "$helper" ] && [ "$(stat -Lc '%u:%a' -- "$helper")" = '0:4755' ] && + grep -q '^NoNewPrivs:[[:space:]]*0$' /proc/self/status; then + mount_options=$(findmnt -n -o VFS-OPTIONS --target "$helper" 2>/dev/null || true) + case ",$mount_options," in + *,nosuid,*|,,) ;; + *) + export PASEO_DESKTOP_SANDBOX_REASON='root-owned SUID helper available' + printf '[linux-sandbox] enabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" + ;; + esac +fi + +export PASEO_DESKTOP_SANDBOX_REASON="user namespaces unavailable${namespace_error:+ ($namespace_error)}; no usable SUID helper" +printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true +exec "$executable" --no-sandbox "$@" diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index b2e19798c8..c54a252d81 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -44,6 +44,7 @@ import { import type { DesktopSettings } from "../settings/desktop-settings.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; +import { describeSandbox } from "../diagnostics/sandbox.js"; import { getDesktopAppLogs } from "../diagnostics/app-logs.js"; import { getDesktopUpdaterDiagnostics } from "../diagnostics/updater.js"; import { @@ -408,6 +409,11 @@ export function createDaemonCommandHandlers(): Record restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), + desktop_sandbox_diagnostics: () => + describeSandbox({ + disabled: app.commandLine.hasSwitch("no-sandbox"), + launcherReason: process.env.PASEO_DESKTOP_SANDBOX_REASON, + }), desktop_app_logs: () => getDesktopAppLogs(), desktop_update_diagnostics: () => getDesktopUpdaterDiagnostics(), desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000, diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index c58c3f05e4..f38e679d3e 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -177,3 +177,16 @@ describe("desktop packaging", () => { } }); }); + +it("installs the Linux helper as root-owned 4755 regardless of root's namespace access", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + expect(config).toContain("afterInstall: scripts/linux-sandbox/after-install.tpl"); + const installer = readFileSync( + join(packageRoot, "scripts/linux-sandbox/after-install.tpl"), + "utf8", + ); + expect(installer).not.toContain("unshare"); + expect(installer).toContain("chown root:root '/opt/${sanitizedProductName}/chrome-sandbox'"); + expect(installer).toContain("chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox'"); + expect(installer).not.toContain("chmod 0755"); +}); diff --git a/packages/desktop/src/daemon/linux-launcher.posix.test.ts b/packages/desktop/src/daemon/linux-launcher.posix.test.ts new file mode 100644 index 0000000000..ba7a8ec02b --- /dev/null +++ b/packages/desktop/src/daemon/linux-launcher.posix.test.ts @@ -0,0 +1,123 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +// The launcher reads Linux /proc state as well as POSIX command interfaces. +const it = test.runIf(process.platform === "linux"); + +const require = createRequire(import.meta.url); +const afterPack = require("../../scripts/after-pack.js").default; + +async function launch( + options: { + namespaces?: boolean; + helper?: string; + mount?: string; + env?: NodeJS.ProcessEnv; + args?: string[]; + symlink?: boolean; + rerun?: boolean; + } = {}, +) { + const root = mkdtempSync(join(tmpdir(), "paseo-launcher-")); + try { + const app = join(root, "app with spaces"); + const commands = join(root, "commands"); + mkdirSync(app); + mkdirSync(commands); + writeFileSync( + join(app, "Paseo"), + `#!${process.execPath}\nconsole.log(JSON.stringify(process.argv.slice(2)));\n`, + ); + chmodSync(join(app, "Paseo"), 0o755); + // The command interface represents the host's userns policy, independent of CI's host. + writeFileSync(join(commands, "unshare"), `#!/bin/sh\nexit ${options.namespaces ? 0 : 1}\n`); + chmodSync(join(commands, "unshare"), 0o755); + for (const [name, output] of Object.entries({ + stat: options.helper ?? "1000:755", + findmnt: options.mount ?? "rw", + })) { + writeFileSync(join(commands, name), `#!/bin/sh\nprintf '%s\\n' '${output}'\n`); + chmodSync(join(commands, name), 0o755); + } + writeFileSync(join(app, "chrome-sandbox"), "helper"); + chmodSync(join(app, "chrome-sandbox"), 0o755); + await afterPack({ appOutDir: app, electronPlatformName: "linux", arch: 1 }); + if (options.rerun) await afterPack({ appOutDir: app, electronPlatformName: "linux", arch: 1 }); + const executablePath = options.symlink ? join(root, "paseo") : join(app, "Paseo"); + if (options.symlink) symlinkSync(join(app, "Paseo"), executablePath); + const args = options.args ?? ["path with spaces", "$(touch never)", "semi;colon", "*.txt"]; + const result = spawnSync(executablePath, args, { + encoding: "utf8", + env: { + ...process.env, + FORCE_COLOR: undefined, + PATH: `${commands}:${process.env.PATH}`, + APPIMAGE: "/tmp/Paseo.AppImage", + PASEO_DESKTOP_SMOKE: "0", + ...options.env, + }, + }); + expect(result.status, result.stderr).toBe(0); + return { args: JSON.parse(result.stdout), stderr: result.stderr, input: args }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +it("launches a portable app with the fallback on argv when user namespaces are denied", async () => { + const result = await launch(); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); + expect(result.stderr).toContain("[linux-sandbox] disabled"); +}); + +it("keeps sandboxing for AppImage when the real user can create namespaces", async () => { + const result = await launch({ namespaces: true }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] enabled: user namespaces available"); +}); + +it("keeps sandboxing via the installed root-owned helper when userns is denied", async () => { + const result = await launch({ helper: "0:4755", env: { APPIMAGE: "" } }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] enabled: root-owned SUID helper available"); +}); + +it("does not trust 4755 on a nosuid mount", async () => { + const result = await launch({ helper: "0:4755", mount: "rw,nosuid,nodev" }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("preserves sandbox flags and all Node entrypoint arguments without probing", async () => { + const result = await launch({ env: { ELECTRON_RUN_AS_NODE: "1" }, args: ["--version"] }); + expect(result.args).toEqual(["--version"]); + expect(result.stderr).toBe(""); +}); + +it("reports an explicit user override without injecting a duplicate", async () => { + const result = await launch({ namespaces: true, args: ["--no-sandbox", "--version"] }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] disabled: requested by --no-sandbox"); +}); + +it("resolves symlink launches and keeps the real executable intact on repeated packaging", async () => { + const result = await launch({ symlink: true, rerun: true }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("does not depend on APPIMAGE being present for an extracted portable app", async () => { + const result = await launch({ env: { APPIMAGE: "" } }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("applies a debugging environment sandbox override before Chromium starts", async () => { + const result = await launch({ + namespaces: true, + env: { PASEO_ELECTRON_FLAGS: "--disable-gpu\t--no-sandbox" }, + }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); + expect(result.stderr).toContain("requested by PASEO_ELECTRON_FLAGS"); +}); diff --git a/packages/desktop/src/diagnostics/sandbox.test.ts b/packages/desktop/src/diagnostics/sandbox.test.ts new file mode 100644 index 0000000000..e9e80040ca --- /dev/null +++ b/packages/desktop/src/diagnostics/sandbox.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import { describeSandbox } from "./sandbox"; + +it("reports a portable fallback independently of the renderer's Node isolation setting", () => { + expect( + describeSandbox({ + disabled: true, + launcherReason: "user namespaces unavailable; no usable SUID helper", + }), + ).toEqual({ + enabled: false, + reason: "user namespaces unavailable; no usable SUID helper", + }); +}); + +it("reports enabled defaults and explicit overrides without a packaged launcher", () => { + expect(describeSandbox({ disabled: false })).toEqual({ + enabled: true, + reason: "Chromium default", + }); + expect(describeSandbox({ disabled: true })).toEqual({ + enabled: false, + reason: "requested by --no-sandbox", + }); +}); diff --git a/packages/desktop/src/diagnostics/sandbox.ts b/packages/desktop/src/diagnostics/sandbox.ts new file mode 100644 index 0000000000..af10c25397 --- /dev/null +++ b/packages/desktop/src/diagnostics/sandbox.ts @@ -0,0 +1,10 @@ +export function describeSandbox(input: { disabled: boolean; launcherReason?: string }): { + enabled: boolean; + reason: string; +} { + return { + enabled: !input.disabled, + reason: + input.launcherReason ?? (input.disabled ? "requested by --no-sandbox" : "Chromium default"), + }; +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 64cf4a9519..63ab83281a 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -330,13 +330,6 @@ if (forcedUserDataDir) { } } -// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID -// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the -// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code. -if (process.platform === "linux" && process.env.APPIMAGE) { - app.commandLine.appendSwitch("no-sandbox"); -} - // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). // Must run before app.whenReady(). @@ -349,6 +342,16 @@ if (electronFlags) { log.info("[electron-flags]", electronFlags); } +if (process.platform === "linux") { + // Keep the desktop/dock identity independent of the wrapped Electron filename. + app.setDesktopName("Paseo.desktop"); + if (!app.commandLine.hasSwitch("class")) app.commandLine.appendSwitch("class", "Paseo"); + log.info("[linux-sandbox]", { + enabled: !app.commandLine.hasSwitch("no-sandbox"), + reason: process.env.PASEO_DESKTOP_SANDBOX_REASON ?? "Chromium default", + }); +} + let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ argv: process.argv, isDefaultApp: process.defaultApp, diff --git a/public-docs/index.md b/public-docs/index.md index 7bf22016a4..f93216883d 100644 --- a/public-docs/index.md +++ b/public-docs/index.md @@ -16,6 +16,27 @@ Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub rele The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Pair Device**. +### Linux + +Use the `.deb` on Debian/Ubuntu or the `.rpm` on Fedora to keep Chromium's sandbox available even when your distribution restricts user namespaces. The installer configures the bundled sandbox helper; you do not need to change system security settings. + +For an AppImage, make the download executable and open it: + +```bash +chmod +x Paseo-x86_64.AppImage +./Paseo-x86_64.AppImage +``` + +If it reports `error loading libfuse.so.2`, run without FUSE: + +```bash +./Paseo-x86_64.AppImage --appimage-extract-and-run +``` + +Alternatively, install `libfuse2t64` on Ubuntu 24.04 or newer (`sudo apt install libfuse2t64`), or your distribution's FUSE 2 compatibility package. This dependency belongs to the AppImage runtime, before Paseo starts. + +Paseo checks sandbox availability each time it launches. AppImage and extracted tar archives retain sandboxing when user namespaces work. On a restricted host without a usable installed helper, they launch with Chromium's sandbox disabled. Prefer the installed package if you require OS process isolation. **Settings → Diagnostics → App Diagnostics** reports the sandbox state and reason; the desktop log records the same decision. An explicit `--no-sandbox` argument overrides the automatic choice. + ## Server / CLI For headless machines, dev boxes, or any setup where you want the daemon running without the desktop UI: