diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c555a61432..8d87038922 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 + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm - name: Install Playwright browser if: ${{ runner.os == 'Linux' }} @@ -226,17 +226,38 @@ 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 dpkg -i 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: Upload packaged smoke diagnostics uses: actions/upload-artifact@v4 - if: ${{ failure() && runner.os == 'Linux' }} + if: ${{ always() && 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..c26e142dad 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -218,7 +218,7 @@ jobs: permissions: contents: write packages: read - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -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 @@ -277,8 +277,28 @@ jobs: build_args+=("-c.publish.channel=$RELEASE_CHANNEL") npm run build:desktop "${build_args[@]}" + - name: Smoke installed artifacts with restricted user namespaces + 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 dpkg -i 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 + 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: 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 342fa9eaf9..28415abc8c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -162,6 +162,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` +- Linux artifact launch checks run with both restricted and usable user namespaces 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..f201402bb3 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. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix runs the same Linux artifact checks before publishing and retains host-native macOS and Windows smokes. + +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 f465c32ac1..24ee4a2bf9 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -302,3 +302,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 1ffa37b950..f243770490 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", @@ -145,3 +146,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/evidence/linux-sandbox/appimage-diagnostics.png b/packages/desktop/e2e/evidence/linux-sandbox/appimage-diagnostics.png new file mode 100644 index 0000000000..8bec53ca7e Binary files /dev/null and b/packages/desktop/e2e/evidence/linux-sandbox/appimage-diagnostics.png differ diff --git a/packages/desktop/e2e/evidence/linux-sandbox/deb-diagnostics.png b/packages/desktop/e2e/evidence/linux-sandbox/deb-diagnostics.png new file mode 100644 index 0000000000..c901984ab8 Binary files /dev/null and b/packages/desktop/e2e/evidence/linux-sandbox/deb-diagnostics.png differ diff --git a/packages/desktop/e2e/evidence/linux-sandbox/deb-workspace.png b/packages/desktop/e2e/evidence/linux-sandbox/deb-workspace.png new file mode 100644 index 0000000000..b7337c5a70 Binary files /dev/null and b/packages/desktop/e2e/evidence/linux-sandbox/deb-workspace.png differ diff --git a/packages/desktop/e2e/linux-artifact-smoke.js b/packages/desktop/e2e/linux-artifact-smoke.js new file mode 100644 index 0000000000..77bca8bb51 --- /dev/null +++ b/packages/desktop/e2e/linux-artifact-smoke.js @@ -0,0 +1,59 @@ +// 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 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, "deb"); + const helper = fs.statSync("/opt/Paseo/chrome-sandbox"); + if (helper.uid !== 0 || (helper.mode & 0o7777) !== 0o4755) { + throw new Error("Installed .deb did not provide a root-owned 4755 helper"); + } + await smokePackagedDesktopApp({ appPath: "/opt/Paseo", expectedSandbox: true }); + + 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 b07b75df4d..58ce0af119 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -67,52 +67,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], }; } @@ -315,7 +280,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; @@ -323,13 +288,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")); @@ -805,10 +772,73 @@ async function stopCliDaemon({ appPath, env }) { }); } -async function smokePackagedDesktopApp({ appPath }) { - const executablePath = getExecutablePath(appPath); +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("")}`); + } + } +} + +async function smokePackagedDesktopApp({ + appPath, + executablePath = getExecutablePath(appPath), + launchArgs = [], + expectedSandbox, +}) { assertExecutable(executablePath, "Packaged app executable"); - ensureLinuxSandboxPermissions(appPath); await smokeColdCliDaemonStart({ appPath }); const userData = createTempDir("paseo-smoke-user-data-"); @@ -832,7 +862,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", @@ -884,12 +914,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 4d3e5531eb..3b7fde15f8 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -37,6 +37,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 { @@ -531,6 +532,11 @@ export function createDaemonCommandHandlers(): Record stopDesktopDaemon(parseDesktopDaemonStopReason(args)), restart_desktop_daemon: () => 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: