fix(desktop): preserve Linux sandboxing wherever the host allows it (#4447)

* fix(desktop): preserve Linux sandboxing wherever the host allows it

Chromium checks its Linux sandbox before main.js, while package installation probes run as root and miss AppArmor restrictions on desktop users. Choose the launch flags before exec and always install the SUID alternative for native packages. Test published artifacts as an ordinary user with namespaces both denied and allowed; never repair permissions in the smoke harness.

* fix(desktop): retain the Linux release build baseline

* fix(ci): allow canceled desktop smoke uploads to stop

* test(desktop): verify the installed RPM sandbox under AppArmor

* fix(ci): resolve packaged desktop dependencies before installation

* chore(desktop): keep QA evidence out of the repository
This commit is contained in:
Mohamed Boudra
2026-09-11 09:05:26 +02:00
committed by GitHub
parent 0110302b6f
commit 772ff8d6f3
21 changed files with 597 additions and 69 deletions
+39 -6
View File
@@ -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
+2 -2
View File
@@ -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
+1
View File
@@ -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.
+6 -2
View File
@@ -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:
@@ -335,3 +335,20 @@ export function readLegacySkillSelection(): Promise<AgentSkillSelection | null>
export async function deleteLegacySkillSelection(): Promise<void> {
await invokeDesktopCommand("delete_legacy_skill_selection");
}
export interface DesktopSandboxDiagnostics {
enabled: boolean;
reason: string;
}
export async function getDesktopSandboxDiagnostics(): Promise<DesktopSandboxDiagnostics> {
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 };
}
@@ -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");
});
@@ -1,4 +1,6 @@
import {
getDesktopSandboxDiagnostics,
type DesktopSandboxDiagnostics,
getDesktopAppLogs,
getDesktopDaemonLogs,
getDesktopDaemonStatus,
@@ -19,6 +21,7 @@ export interface DesktopDiagnosticCollectionResult {
}
export interface DesktopDiagnosticSources {
getSandboxDiagnostics: () => Promise<DesktopSandboxDiagnostics>;
getStatus: () => Promise<DesktopDaemonStatus>;
getDaemonLogs: () => Promise<DesktopDaemonLogs>;
getAppLogs: () => Promise<DesktopAppLogs>;
@@ -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;
@@ -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;
});
+86 -51
View File
@@ -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}`);
},
+6
View File
@@ -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
+6
View File
@@ -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(
@@ -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
@@ -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);
};
@@ -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 "$@"
@@ -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<string, DesktopCommandHand
),
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,
@@ -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");
});
@@ -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");
});
@@ -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",
});
});
@@ -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"),
};
}
+10 -7
View File
@@ -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,
+21
View File
@@ -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: