mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
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:
@@ -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;
|
||||
});
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user