From 718f4a99f09d225a6cf92f25aaf3bcba33806565 Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 14 Aug 2026 03:51:23 +0200 Subject: [PATCH] fix: AppImage required --no-sandbox Not tested enough but works on my machine --- agents-docs/LESSONS-INDEX.md | 1 + agents-docs/LESSONS.md | 7 ++ electron/CONTEXT.md | 2 + electron/app/flags.ts | 19 +-- electron/app/linux-launcher.rules.spec.ts | 147 ++++++++++++++++++++++ electron/app/linux-launcher.rules.ts | 97 ++++++++++++++ package.json | 1 + tools/after-pack.js | 34 +++++ 8 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 electron/app/linux-launcher.rules.spec.ts create mode 100644 electron/app/linux-launcher.rules.ts create mode 100644 tools/after-pack.js diff --git a/agents-docs/LESSONS-INDEX.md b/agents-docs/LESSONS-INDEX.md index 2fe2cdb..0c87946 100644 --- a/agents-docs/LESSONS-INDEX.md +++ b/agents-docs/LESSONS-INDEX.md @@ -78,4 +78,5 @@ Tags help grepping: `rg '\\[attachments\\]' agents-docs/LESSONS-INDEX.md` - Record whether the user is in a call before calling zero RTP a failure — `[testing] [voice] [verification]` - Assert continuity when the state you broke repairs itself — `[testing] [voice] [verification]` - Missing gossip about a peer is not evidence it left voice — `[voice] [webrtc] [realtime]` +- `app.commandLine.appendSwitch` cannot disable the Chromium sandbox — `[electron] [packaging] [linux]` diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index cc98523..8b3f830 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. ## Lessons +### `app.commandLine.appendSwitch` cannot disable the Chromium sandbox [electron] [packaging] [linux] + +- **Trigger:** a packaged Linux build shows only the window background and spams `Unable to access(W_OK|X_OK) /tmp` / `Creating shared memory in /tmp/... failed`, while the same build works when the user types `--no-sandbox`. +- **Rule:** sandbox and Ozone switches only count when they are on the real command line at process start. Never pair a runtime `appendSwitch('no-sandbox')` with `appendSwitch('disable-dev-shm-usage')` — the first is a no-op because the zygote has already forked, the second takes effect and redirects shared memory into `/tmp`, which the still-active sandbox denies forever. +- **Why:** electron-builder's AppImage `AppRun` is a bash script that execs `$APPDIR/ "$@"` and ignores the bundled desktop entry, so `linux.executableArgs` never reaches a double-click or terminal launch. Only an installed `.desktop` file passes those arguments. +- **Example:** `electron/app/linux-launcher.rules.ts` generates the launcher that `tools/after-pack.js` installs in place of the real binary (renamed `-bin`); it enables `--no-sandbox` only where unprivileged user namespaces are denied (Ubuntu 24.04+ AppArmor, hardened kernels), since an AppImage payload is mounted `nosuid` and cannot fall back to the SUID helper. + ### A hold-on-unknown rule needs every attach site behind it [voice] [webrtc] [realtime] - **Trigger:** replacing a strict media gate with "hold an established path when nothing confirms the peer", while some fast path still attaches the track without asking the rule. diff --git a/electron/CONTEXT.md b/electron/CONTEXT.md index 6b4eec1..a25bade 100644 --- a/electron/CONTEXT.md +++ b/electron/CONTEXT.md @@ -21,6 +21,7 @@ Owns the desktop runtime: the Electron main process, the preload bridge that exp | **Local API server** | An in-process HTTP server (`electron/api/local-api-server.ts`) that serves the prebuilt Docusaurus docs and OpenAPI views to the renderer over `http://localhost:/`. | "internal API" | | **Plugin library** | The plugin loader (`electron/plugin-library.ts`) — resolves manifests, validates entry points, and prepares the sandbox the renderer mounts plugins into. | "plugin manager" | | **Data archive** | The export/import format implemented in `electron/data-archive.ts` for moving a user's local database between installs. | "backup" | +| **Linux launcher** | The shell script installed as the packaged Linux executable by `tools/after-pack.js`; built by `electron/app/linux-launcher.rules.ts`, it picks the sandbox switches and hands over to the renamed real binary `-bin`. | "wrapper", "AppRun" | ## Relationships @@ -49,6 +50,7 @@ Owns the desktop runtime: the Electron main process, the preload bridge that exp - Every schema change is accompanied by a **TypeORM migration**; the database is never mutated outside the migration system. - IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main. - The **Preload bridge** exposes a frozen, allow-listed set of methods; adding a method requires touching both `preload.ts` and the matching handler. +- Chromium sandbox and Ozone switches are only ever set on the real command line — the **Linux launcher** for packaged builds, the launch scripts in development. `app.commandLine.appendSwitch` runs too late for them and must not be used to fake it. ## Flagged ambiguities diff --git a/electron/app/flags.ts b/electron/app/flags.ts index ee4eb50..e0e470a 100644 --- a/electron/app/flags.ts +++ b/electron/app/flags.ts @@ -4,7 +4,6 @@ import { readDesktopSettings } from '../desktop-settings'; export function configureAppFlags(): void { configureDesktopBranding(); - linuxSpecificFlags(); networkFlags(); setupGpuEncodingFlags(); chromiumFlags(); @@ -21,6 +20,10 @@ function chromiumFlags(): void { const enabledFeatures: string[] = []; if (process.platform === 'linux') { + // Sandbox and Ozone platform selection happen before this file runs. The + // packaged launcher script and the dev launch scripts pass those switches + // on the real command line instead. + // PipeWire-based audio pipeline for screen share audio capture enabledFeatures.push('AudioServiceOutOfProcess'); // PipeWire-based screen capture so the xdg-desktop-portal system picker works @@ -38,20 +41,6 @@ function chromiumFlags(): void { } } -function linuxSpecificFlags(): void { - if (process.platform !== 'linux') { - return; - } - - // Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues - app.commandLine.appendSwitch('no-sandbox'); - app.commandLine.appendSwitch('disable-dev-shm-usage'); - - // Chromium chooses the Linux Ozone platform before Electron runs this file. - // The launch scripts pass `--ozone-platform=wayland` up front for Wayland - // sessions so the browser process selects the correct backend early enough. -} - function networkFlags(): void { // Accept self-signed certificates in development (for --ssl dev server) if (process.env['SSL'] === 'true') { diff --git a/electron/app/linux-launcher.rules.spec.ts b/electron/app/linux-launcher.rules.spec.ts new file mode 100644 index 0000000..588e523 --- /dev/null +++ b/electron/app/linux-launcher.rules.spec.ts @@ -0,0 +1,147 @@ +import { execFileSync } from 'node:child_process'; +import { + mkdtempSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { buildLinuxLauncherScript, resolveLinuxLauncherNames } from './linux-launcher.rules'; + +interface KernelFlags { + apparmorRestriction: string; + unprivilegedUsernsClone: string; + maxUserNamespaces: string; +} + +const PERMISSIVE_KERNEL: KernelFlags = { + apparmorRestriction: '0', + unprivilegedUsernsClone: '1', + maxUserNamespaces: '15000' +}; + +let workspace = ''; + +function writeKernelFlags(flags: KernelFlags): Record { + const paths = { + apparmorRestriction: join(workspace, 'apparmor_restrict_unprivileged_userns'), + unprivilegedUsernsClone: join(workspace, 'unprivileged_userns_clone'), + maxUserNamespaces: join(workspace, 'max_user_namespaces') + }; + + for (const key of Object.keys(paths) as (keyof KernelFlags)[]) { + writeFileSync(paths[key], `${flags[key]}\n`, 'utf8'); + } + + return paths; +} + +function runLauncher(flags: KernelFlags, args: string[] = []): string { + const paths = writeKernelFlags(flags); + const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames('toju'); + const launcherPath = join(workspace, launcherFileName); + const binaryPath = join(workspace, binaryFileName); + + writeFileSync(binaryPath, '#!/bin/sh\nprintf \'%s\\n\' "$@"\n', { encoding: 'utf8', mode: 0o755 }); + writeFileSync( + launcherPath, + buildLinuxLauncherScript({ + binaryFileName, + apparmorRestrictionPath: paths.apparmorRestriction, + unprivilegedUsernsClonePath: paths.unprivilegedUsernsClone, + maxUserNamespacesPath: paths.maxUserNamespaces + }), + { encoding: 'utf8', mode: 0o755 } + ); + + return execFileSync(launcherPath, args, { encoding: 'utf8' }).trim(); +} + +describe('buildLinuxLauncherScript', () => { + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), 'toju-launcher-')); + }); + + afterEach(() => { + rmSync(workspace, { force: true, recursive: true }); + }); + + it('keeps the sandbox on when the kernel allows unprivileged user namespaces', () => { + expect(runLauncher(PERMISSIVE_KERNEL)).toBe(''); + }); + + it('disables the sandbox when AppArmor confines unprivileged user namespaces', () => { + const output = runLauncher({ ...PERMISSIVE_KERNEL, apparmorRestriction: '1' }); + + expect(output).toBe('--no-sandbox'); + }); + + it('disables the sandbox when the kernel forbids unprivileged namespace cloning', () => { + const output = runLauncher({ ...PERMISSIVE_KERNEL, unprivilegedUsernsClone: '0' }); + + expect(output).toBe('--no-sandbox'); + }); + + it('disables the sandbox when no user namespaces are available at all', () => { + const output = runLauncher({ ...PERMISSIVE_KERNEL, maxUserNamespaces: '0' }); + + expect(output).toBe('--no-sandbox'); + }); + + it('forwards launch arguments to the real binary', () => { + const output = runLauncher(PERMISSIVE_KERNEL, ['toju://invite/abc', '--ozone-platform=wayland']); + + expect(output.split('\n')).toEqual(['toju://invite/abc', '--ozone-platform=wayland']); + }); + + it('never repeats a sandbox switch the caller already supplied', () => { + const output = runLauncher( + { ...PERMISSIVE_KERNEL, apparmorRestriction: '1' }, + ['--no-sandbox', '%U'] + ); + + expect(output.split('\n')).toEqual(['--no-sandbox', '%U']); + }); + + it('assumes a blocked sandbox is fine when the kernel switches are unreadable', () => { + const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames('toju'); + const launcherPath = join(workspace, launcherFileName); + + writeFileSync( + join(workspace, binaryFileName), + '#!/bin/sh\nprintf \'%s\\n\' "$@"\n', + { encoding: 'utf8', mode: 0o755 } + ); + + writeFileSync( + launcherPath, + buildLinuxLauncherScript({ + binaryFileName, + apparmorRestrictionPath: join(workspace, 'missing-apparmor'), + unprivilegedUsernsClonePath: join(workspace, 'missing-clone'), + maxUserNamespacesPath: join(workspace, 'missing-max') + }), + { encoding: 'utf8', mode: 0o755 } + ); + + expect(execFileSync(launcherPath, { encoding: 'utf8' }).trim()).toBe(''); + }); +}); + +describe('resolveLinuxLauncherNames', () => { + it('keeps the published executable name for the launcher and renames the binary', () => { + expect(resolveLinuxLauncherNames('toju')).toEqual({ + launcherFileName: 'toju', + binaryFileName: 'toju-bin' + }); + }); +}); diff --git a/electron/app/linux-launcher.rules.ts b/electron/app/linux-launcher.rules.ts new file mode 100644 index 0000000..ee02629 --- /dev/null +++ b/electron/app/linux-launcher.rules.ts @@ -0,0 +1,97 @@ +export const LINUX_LAUNCHER_BINARY_SUFFIX = '-bin'; + +export const APPARMOR_USERNS_RESTRICTION_PATH = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'; +export const UNPRIVILEGED_USERNS_CLONE_PATH = '/proc/sys/kernel/unprivileged_userns_clone'; +export const MAX_USER_NAMESPACES_PATH = '/proc/sys/user/max_user_namespaces'; + +export interface LinuxLauncherNames { + launcherFileName: string; + binaryFileName: string; +} + +export interface LinuxLauncherScriptOptions { + binaryFileName: string; + apparmorRestrictionPath?: string; + unprivilegedUsernsClonePath?: string; + maxUserNamespacesPath?: string; +} + +export function resolveLinuxLauncherNames(executableName: string): LinuxLauncherNames { + return { + launcherFileName: executableName, + binaryFileName: `${executableName}${LINUX_LAUNCHER_BINARY_SUFFIX}` + }; +} + +/** + * Chromium reads `--no-sandbox` while the browser process boots, long before + * the main script runs, so `app.commandLine.appendSwitch` cannot influence it. + * The packaged executable is therefore this script, which decides before + * handing over to the real binary. + * + * The sandbox stays on wherever the kernel can host it. It is dropped only + * where unprivileged user namespaces are denied - Ubuntu 24.04+ confines + * unconfined binaries through AppArmor, and hardened kernels disable the + * namespaces outright. An AppImage cannot fall back to the SUID helper because + * its payload is mounted `nosuid`, so without this the app aborts at startup. + */ +export function buildLinuxLauncherScript(options: LinuxLauncherScriptOptions): string { + const apparmorRestrictionPath = options.apparmorRestrictionPath ?? APPARMOR_USERNS_RESTRICTION_PATH; + const unprivilegedUsernsClonePath = options.unprivilegedUsernsClonePath ?? UNPRIVILEGED_USERNS_CLONE_PATH; + const maxUserNamespacesPath = options.maxUserNamespacesPath ?? MAX_USER_NAMESPACES_PATH; + + return [ + '#!/bin/sh', + '# Generated during packaging. Chromium only honours --no-sandbox when it is', + '# present on the real command line, so the decision happens here.', + 'set -eu', + '', + 'launcher_path="$0"', + '', + 'case "$launcher_path" in', + ' */*) ;;', + ' *) launcher_path="$(command -v -- "$launcher_path" 2>/dev/null || printf \'%s\' "$launcher_path")" ;;', + 'esac', + '', + 'launcher_path="$(readlink -f -- "$launcher_path" 2>/dev/null || printf \'%s\' "$launcher_path")"', + `binary_path="$(dirname -- "$launcher_path")/${options.binaryFileName}"`, + '', + 'read_kernel_flag() {', + ' if [ ! -r "$1" ]; then', + ' printf \'%s\' "$2"', + ' return 0', + ' fi', + '', + ' cat -- "$1" 2>/dev/null || printf \'%s\' "$2"', + '}', + '', + 'sandbox_is_blocked() {', + ` if [ "$(read_kernel_flag ${apparmorRestrictionPath} 0)" = "1" ]; then`, + ' return 0', + ' fi', + '', + ` if [ "$(read_kernel_flag ${unprivilegedUsernsClonePath} 1)" = "0" ]; then`, + ' return 0', + ' fi', + '', + ` if [ "$(read_kernel_flag ${maxUserNamespacesPath} 1)" = "0" ]; then`, + ' return 0', + ' fi', + '', + ' return 1', + '}', + '', + 'for launcher_arg in "$@"; do', + ' case "$launcher_arg" in', + ' --no-sandbox) exec "$binary_path" "$@" ;;', + ' esac', + 'done', + '', + 'if sandbox_is_blocked; then', + ' exec "$binary_path" --no-sandbox "$@"', + 'fi', + '', + 'exec "$binary_path" "$@"', + '' + ].join('\n'); +} diff --git a/package.json b/package.json index 82af705..29ce253 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "directories": { "output": "dist-electron" }, + "afterPack": "tools/after-pack.js", "files": [ "!node_modules", "dist/client/**/*", diff --git a/tools/after-pack.js b/tools/after-pack.js new file mode 100644 index 0000000..702497c --- /dev/null +++ b/tools/after-pack.js @@ -0,0 +1,34 @@ +const fs = require('fs'); +const path = require('path'); + +const { + buildLinuxLauncherScript, + resolveLinuxLauncherNames +} = require('../dist/electron/app/linux-launcher.rules.js'); + +const LAUNCHER_MODE = 0o755; + +function installLinuxLauncher(appOutDir, executableName) { + const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames(executableName); + const launcherPath = path.join(appOutDir, launcherFileName); + const binaryPath = path.join(appOutDir, binaryFileName); + + if (!fs.existsSync(binaryPath)) { + fs.renameSync(launcherPath, binaryPath); + } + + fs.writeFileSync(launcherPath, buildLinuxLauncherScript({ binaryFileName }), 'utf8'); + fs.chmodSync(launcherPath, LAUNCHER_MODE); + + return launcherFileName; +} + +exports.default = async function afterPack(context) { + if (context.electronPlatformName !== 'linux') { + return; + } + + const launcherFileName = installLinuxLauncher(context.appOutDir, context.packager.executableName); + + console.log(` • linux launcher installed file=${launcherFileName}`); +};