From 0be92171b9133306a1816dd7159dcfcc61df2fad Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 28 Jul 2026 21:46:47 +0530 Subject: [PATCH] fix(appimage): unblock release smoke by relaxing the userns sysctl The Linux desktop job has failed every Release Production run since #5189 reached release (promote #5203). The AppImage itself builds, signs and boots fine; the new bounded startup smoke is what fails, deterministically, with exit 133 instead of the expected 124: FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:128] No usable sandbox! If you are running on Ubuntu 23.10+ ... Ubuntu 23.10+ sets kernel.apparmor_restrict_unprivileged_userns=1, which denies unprivileged user-namespace creation to every unconfined process on the runner, so CEF's zygote cannot build its sandbox and aborts on SIGTRAP. #5189 reached for Chromium's second documented remedy, a per-executable AppArmor profile carrying `userns,`. The profile loads successfully but never takes effect, because AppArmor attaches profiles by execve path while the sharun launcher runs the app through the AppDir's bundled dynamic loader rather than exec'ing shared/bin/OpenHuman directly (Chromium then re-execs /proc/self/exe for the zygote). Apply Chromium's first documented remedy instead: relax the sysctl for the smoke window and restore its original value afterwards, including on interrupt. It is path-independent, so it cannot miss the way profile attachment does. The AppArmor profile is retained alongside it -- it is harmless and keeps the narrower grant for hosts without the sysctl. Setting OPENHUMAN_CEF_NO_SANDBOX is deliberately not the fix: it is #[cfg(debug_assertions)]-gated in app/src-tauri/src/lib.rs and therefore inert in the release AppImage, and CEF's command line is built explicitly via command_line_args() so a --no-sandbox argv flag would not reach it either. Keeping the sandbox enabled also means the smoke keeps exercising the real production configuration. The toggle is a no-op on hosts that are already permissive or that have no such sysctl key, so it never reaches for sudo outside the restricted-Ubuntu case it exists for. Refs: https://github.com/tinyhumansai/openhuman/actions/runs/30361949794 --- scripts/release/test-strip-appimage-rpaths.sh | 68 +++++++++++++- scripts/release/validate-appimage-runtime.sh | 94 ++++++++++++++++++- 2 files changed, 156 insertions(+), 6 deletions(-) diff --git a/scripts/release/test-strip-appimage-rpaths.sh b/scripts/release/test-strip-appimage-rpaths.sh index 7c24b612ee..fefb7c1b7c 100644 --- a/scripts/release/test-strip-appimage-rpaths.sh +++ b/scripts/release/test-strip-appimage-rpaths.sh @@ -571,8 +571,23 @@ printf '%s\n' \ ' cp "$4" "$APPARMOR_PROFILE_SNAPSHOT"' \ 'fi' \ >"$APPARMOR_BIN/sudo" -chmod +x "$APPARMOR_BIN/apparmor_parser" "$APPARMOR_BIN/sudo" +# Stand in for a restricted Ubuntu 24.04 host so the sysctl branch is exercised +# deterministically on any development machine, including ones with no sysctl +# key of that name at all. +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [ "${1:-}" = "-n" ]; then' \ + ' printf "%s\n" "1"' \ + ' exit 0' \ + 'fi' \ + 'exit 0' \ + >"$APPARMOR_BIN/sysctl" +chmod +x \ + "$APPARMOR_BIN/apparmor_parser" \ + "$APPARMOR_BIN/sudo" \ + "$APPARMOR_BIN/sysctl" export APPARMOR_RECORD APPARMOR_PROFILE_SNAPSHOT +USERNS_SYSCTL="kernel.apparmor_restrict_unprivileged_userns" PATH="$APPARMOR_BIN:$PATH" install_smoke_userns_profile \ "$RUNTIME_COMPLETE" "$APPARMOR_PROFILE" \ || fail "install_smoke_userns_profile rejected the fixture AppDir" @@ -610,12 +625,16 @@ set -e [ "$apparmor_smoke_status" -eq 37 ] \ || fail "AppArmor wrapper did not preserve smoke failure status 37" printf '%s\n' \ + "--non-interactive sysctl -q -w $USERNS_SYSCTL=0" \ "--non-interactive apparmor_parser --replace $APPARMOR_PROFILE" \ "smoke" \ "--non-interactive apparmor_parser --remove $APPARMOR_PROFILE" \ + "--non-interactive sysctl -q -w $USERNS_SYSCTL=1" \ >"$WORK/apparmor-expected-order" cmp -s "$WORK/apparmor-expected-order" "$APPARMOR_RECORD" \ || fail "AppArmor profile was not loaded before and removed after a failing smoke" +[ ! -e "$APPARMOR_PROFILE.sysctl" ] \ + || fail "userns sysctl state file survived a failing smoke" APPARMOR_TERM_RECORD="$WORK/apparmor-term-command-record" APPARMOR_TERM_MARKER="$WORK/apparmor-term-smoke-started" @@ -665,14 +684,61 @@ set -e [ "$apparmor_term_status" -eq 143 ] \ || fail "AppArmor TERM fixture exited $apparmor_term_status instead of 143" printf '%s\n' \ + "--non-interactive sysctl -q -w $USERNS_SYSCTL=0" \ "--non-interactive apparmor_parser --replace $APPARMOR_PROFILE" \ "smoke-start" \ "--non-interactive apparmor_parser --remove $APPARMOR_PROFILE" \ + "--non-interactive sysctl -q -w $USERNS_SYSCTL=1" \ >"$WORK/apparmor-term-expected-order" cmp -s "$WORK/apparmor-term-expected-order" "$APPARMOR_TERM_RECORD" \ || fail "AppArmor profile was not removed exactly once after TERM" +[ ! -e "$APPARMOR_PROFILE.sysctl" ] \ + || fail "userns sysctl state file survived a TERM-interrupted smoke" echo "[test-rpaths] ok: CI smoke grants userns only to the extracted executable" +# The sysctl toggle must be a no-op on hosts that are already permissive or +# that have no AppArmor userns restriction at all, so the release smoke never +# needs sudo outside the restricted-Ubuntu case it exists for. +USERNS_NOOP_BIN="$WORK/userns-noop-bin" +USERNS_NOOP_RECORD="$WORK/userns-noop-command-record" +USERNS_NOOP_STATE="$WORK/userns-noop.sysctl" +mkdir -p "$USERNS_NOOP_BIN" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\n" "$*" >>"$USERNS_NOOP_RECORD"' \ + >"$USERNS_NOOP_BIN/sudo" +chmod +x "$USERNS_NOOP_BIN/sudo" +export USERNS_NOOP_RECORD +for permissive_value in 0 ""; do + : >"$USERNS_NOOP_RECORD" + rm -f "$USERNS_NOOP_STATE" + if [ -n "$permissive_value" ]; then + printf '%s\n' \ + '#!/usr/bin/env bash' \ + '[ "${1:-}" = "-n" ] && printf "%s\n" "0"' \ + 'exit 0' \ + >"$USERNS_NOOP_BIN/sysctl" + else + # No such key on this host: sysctl exits non-zero and prints nothing. + printf '%s\n' '#!/usr/bin/env bash' 'exit 1' \ + >"$USERNS_NOOP_BIN/sysctl" + fi + chmod +x "$USERNS_NOOP_BIN/sysctl" + PATH="$USERNS_NOOP_BIN:$PATH" \ + relax_smoke_userns_restriction "$USERNS_NOOP_STATE" >/dev/null \ + || fail "relax_smoke_userns_restriction failed on a permissive host" + [ ! -s "$USERNS_NOOP_RECORD" ] \ + || fail "relax_smoke_userns_restriction used sudo on a permissive host" + [ ! -e "$USERNS_NOOP_STATE" ] \ + || fail "relax_smoke_userns_restriction recorded state it never changed" + PATH="$USERNS_NOOP_BIN:$PATH" \ + restore_smoke_userns_restriction "$USERNS_NOOP_STATE" >/dev/null \ + || fail "restore_smoke_userns_restriction failed without recorded state" + [ ! -s "$USERNS_NOOP_RECORD" ] \ + || fail "restore_smoke_userns_restriction used sudo without recorded state" +done +echo "[test-rpaths] ok: userns sysctl toggle is a no-op on permissive hosts" + assert_runtime_layout_rejected missing-anylinux \ "missing anylinux.so" remove_anylinux assert_runtime_layout_rejected missing-libxdo \ diff --git a/scripts/release/validate-appimage-runtime.sh b/scripts/release/validate-appimage-runtime.sh index e20c96f657..15403da363 100755 --- a/scripts/release/validate-appimage-runtime.sh +++ b/scripts/release/validate-appimage-runtime.sh @@ -10,9 +10,12 @@ # Env: # APPIMAGE_RUNTIME_SMOKE — set to 1 to require the bounded Xvfb startup smoke; # otherwise only static final-artifact checks run -# APPIMAGE_RUNTIME_APPARMOR_USERNS — set to 1 on Ubuntu 24.04 CI to load a -# temporary, per-executable AppArmor profile granting -# Chromium's sandbox access to user namespaces +# APPIMAGE_RUNTIME_APPARMOR_USERNS — set to 1 on Ubuntu 24.04 CI to give +# Chromium's sandbox access to user namespaces for +# the smoke window: relaxes +# kernel.apparmor_restrict_unprivileged_userns (and +# restores it afterwards) and loads a temporary, +# per-executable AppArmor profile set -euo pipefail @@ -208,6 +211,67 @@ smoke_extracted_apprun() { echo "[appimage-runtime] Application remained alive for the 15-second startup window" } +# Ubuntu 23.10+ (the hosted ubuntu-24.04 runner included) sets +# `kernel.apparmor_restrict_unprivileged_userns=1`, which denies unprivileged +# user-namespace creation to every *unconfined* process on the box. Chromium's +# zygote needs one, so CEF aborts during startup with +# `FATAL:zygote_host_impl_linux.cc] No usable sandbox!` and the smoke sees exit +# 133 (SIGTRAP) instead of the expected 124 (still alive at the timeout). +# +# `install_smoke_userns_profile` below is Chromium's second documented remedy — +# a per-executable AppArmor profile carrying `userns,`. It loads successfully +# but never takes effect here, because the profile attaches by execve path and +# the sharun launcher runs the app through the AppDir's bundled dynamic loader +# rather than exec'ing `shared/bin/OpenHuman` directly (Chromium then re-execs +# `/proc/self/exe` for the zygote). Toggling the sysctl is Chromium's first +# documented remedy and is path-independent, so it cannot miss the way the +# profile attachment does. The AppArmor profile is retained alongside it: it is +# harmless, and it keeps the narrower per-executable grant in place for hosts +# where the sysctl is unavailable. +# +# The original value is restored after the smoke window so the runner is left +# exactly as it was found. +SMOKE_USERNS_SYSCTL="kernel.apparmor_restrict_unprivileged_userns" + +smoke_userns_sysctl_value() { + sysctl -n "$SMOKE_USERNS_SYSCTL" 2>/dev/null || true +} + +relax_smoke_userns_restriction() { + local previous_file="$1" + local current + current="$(smoke_userns_sysctl_value)" + + if [ -z "$current" ]; then + echo "[appimage-runtime] $SMOKE_USERNS_SYSCTL is absent; unprivileged user namespaces are already unrestricted" + return 0 + fi + if [ "$current" = "0" ]; then + echo "[appimage-runtime] $SMOKE_USERNS_SYSCTL is already 0; leaving it unchanged" + return 0 + fi + + command -v sudo >/dev/null 2>&1 \ + || { runtime_validation_error "sudo is required to relax $SMOKE_USERNS_SYSCTL for the AppImage smoke"; return 1; } + sudo --non-interactive sysctl -q -w "$SMOKE_USERNS_SYSCTL=0" \ + || { runtime_validation_error "could not relax $SMOKE_USERNS_SYSCTL for the AppImage smoke"; return 1; } + + printf '%s\n' "$current" >"$previous_file" + echo "[appimage-runtime] Relaxed $SMOKE_USERNS_SYSCTL: $current -> 0 for the smoke window" +} + +restore_smoke_userns_restriction() { + local previous_file="$1" + [ -s "$previous_file" ] || return 0 + + local previous + previous="$(cat "$previous_file")" + rm -f "$previous_file" + sudo --non-interactive sysctl -q -w "$SMOKE_USERNS_SYSCTL=$previous" \ + || { runtime_validation_error "could not restore $SMOKE_USERNS_SYSCTL to $previous"; return 1; } + echo "[appimage-runtime] Restored $SMOKE_USERNS_SYSCTL to $previous" +} + install_smoke_userns_profile() { local appdir="$1" local profile_file="$2" @@ -248,6 +312,8 @@ smoke_extracted_apprun_with_userns() { local log_file="$3" local profile_file="$4" local profile_loaded=0 + # Derived rather than passed so the four-argument contract is unchanged. + local userns_sysctl_file="$profile_file.sysctl" local previous_hup_trap previous_int_trap previous_term_trap previous_hup_trap="$(trap -p HUP)" previous_int_trap="$(trap -p INT)" @@ -266,6 +332,8 @@ smoke_extracted_apprun_with_userns() { remove_smoke_userns_profile "$profile_file" \ || echo "[appimage-runtime] ERROR: AppArmor cleanup failed after smoke interruption" >&2 fi + restore_smoke_userns_restriction "$userns_sysctl_file" \ + || echo "[appimage-runtime] ERROR: $SMOKE_USERNS_SYSCTL restore failed after smoke interruption" >&2 } interrupt_smoke_with_userns() { @@ -278,7 +346,15 @@ smoke_extracted_apprun_with_userns() { trap 'interrupt_smoke_with_userns 130' INT trap 'interrupt_smoke_with_userns 143' TERM + rm -f "$userns_sysctl_file" + if ! relax_smoke_userns_restriction "$userns_sysctl_file"; then + restore_smoke_signal_traps + return 1 + fi + if ! install_smoke_userns_profile "$appdir" "$profile_file"; then + restore_smoke_userns_restriction "$userns_sysctl_file" \ + || echo "[appimage-runtime] ERROR: $SMOKE_USERNS_SYSCTL restore failed after AppArmor setup failure" >&2 restore_smoke_signal_traps return 1 fi @@ -299,10 +375,18 @@ smoke_extracted_apprun_with_userns() { remove_status=$? fi + local restore_status + if restore_smoke_userns_restriction "$userns_sysctl_file"; then + restore_status=0 + else + restore_status=$? + fi + restore_smoke_signal_traps - # Preserve the original smoke failure even if profile cleanup also fails. + # Preserve the original smoke failure even if cleanup also fails. [ "$smoke_status" -eq 0 ] || return "$smoke_status" - return "$remove_status" + [ "$remove_status" -eq 0 ] || return "$remove_status" + return "$restore_status" } validate_final_appimage() (