Tools: The Hotfix Chronicles: What Broke After the Nexus Release and How I Fixed It (v1.5.2 & v1.5.3)

Tools: The Hotfix Chronicles: What Broke After the Nexus Release and How I Fixed It (v1.5.2 & v1.5.3)

I promised I'd fix it. Here's exactly what broke in DotGhostBoard after v1.5.1 — PyInstaller frozen environments, insecure /tmp update paths, missing UI assets, and a polkit regression — and how each one was resolved.

What Happened After v1.5.1 Shipped

Bug 1 — The White Window (PyInstaller Frozen Environment)

Root Cause

The CI Side of This Bug

Bug 2 — Insecure Update Download Path

The Fix: User-Specific Sandbox

The Kamikaze Installer

Bug 3 — Polkit SUID Regression on Kali

Feature: Live Terminal Log (v1.5.3)

Architecture

Verification Checklist

What I Learned

What's Next — v2.0.0 Cerberus

Download This is a follow-up to Engineering the Nexus Release. At the end of that article I wrote: "Stay tuned for v2.0.0 Cerberus." Before Cerberus ships, there's something I owe you — the full post-mortem on what broke in production immediately after that release. Check out the v1.5.3 highlight reel to see the new Live Terminal and Network Sync in action: The Nexus architecture held. mDNS discovery worked. E2EE pairing worked. The sync engine worked. What didn't work: the UI didn't load on any bundled build. Users who downloaded the .AppImage or .deb were greeted with a white window. No stylesheet. No dark theme. Just a blank Qt frame. The app was running — but blind. That was the beginning of the v1.5.2 → v1.5.3 hotfix sprint. When PyInstaller bundles an app, it doesn't just copy files — it extracts them into a temporary directory at runtime called sys._MEIPASS. The problem is that code like this: ...works perfectly when you run python main.py from source, because __file__ points to the project root. But inside a frozen .AppImage, __file__ resolves to a virtual path inside the bundle — and ghost.qss is nowhere near it. The UI stylesheet ghost.qss simply couldn't be found. Qt silently fell back to its default theme. White window. Every asset reference in the codebase was updated to go through resource_path(). The white window was gone. But wait — local builds worked. CI builds didn't. Why? Because the PyInstaller command in the workflow was missing the --add-data directive for the UI directory: The deceptive part: local pyinstaller builds work because pyinstaller finds ui/ghost.qss relative to the working directory. The GitHub Actions runner has a clean checkout with the same structure — but without explicit --add-data, the file is excluded from the bundle spec. Same command, different behavior depending on how PyInstaller resolves paths in its analysis phase. You can verify any .deb build has the file: In v1.5.1, the in-app "Download Update" feature used /tmp/ as the staging area: /tmp/ is world-readable, world-writable, and wiped on reboot. For a one-time download that's fine. For a security tool that downloads and installs packages — it's a TOCTOU (Time-Of-Check to Time-Of-Use) attack surface. An attacker with local access could replace the .deb between download and installation. The install script itself also needed hardening. After dpkg -i finishes, no artifacts should remain: Nothing lingers. No old .deb sitting in a directory. No script to be re-executed. Kali Linux (and some hardened Arch setups) occasionally strip or reset the SUID bit on /usr/bin/pkexec after system updates. This caused the in-app "Download & Install" button to fail silently — pkexec would return a permission error, and the UI would just hang. The fix was embedded directly in the package's postinst script — it runs as root immediately after dpkg -i completes: This is controversial — touching SUID bits in a postinst script is aggressive. But the alternative is users filing issues that are impossible to debug remotely, or writing a setup guide that says "run chmod 4755 /usr/bin/pkexec after installing." The autorepair is the lesser evil. While fixing the above bugs, I replaced the old "Please Wait..." dialog during updates with a live terminal stream. It felt wrong to show a spinner while dpkg was doing real work with real output. The blinking cursor is driven by a QTimer — no threads, no complexity: For any future hotfix, these three commands confirm the build is clean: PyInstaller's --add-data is not optional for anything outside data/. If your app loads a file at runtime that isn't in the directory you passed to --add-data, it won't be in the bundle. PyInstaller doesn't warn you. The app just crashes or falls back silently. Map every resource directory explicitly. sys._MEIPASS is the only reliable truth in a frozen environment. Don't use __file__, don't use os.getcwd(), don't use relative paths. _MEIPASS is where PyInstaller extracted everything — it's the only path you can trust. /tmp/ is fine for scratch. It's not fine for security-sensitive staging. The threat model for a clipboard manager that stores secrets is different from a text editor. The update pipeline needed to match that model. Local builds working ≠ CI builds working. The ghost.qss bug existed for the entire v1.5.x series in bundled form because nobody had tested a clean AppImage build from the CI artifact — only local PyInstaller runs. Add artifact smoke testing to your release checklist. The hotfix series is closed. v1.5.3 is stable. Next up is Cerberus — the Zero-Knowledge Password Vault. The AES-256 foundation from v1.4.0 (Eclipse) is already in place. The design is locked: The core principle of Cerberus: a 1500-word article that contains the word "password" doesn't trigger detection. A 40-character base64 string with high Shannon entropy does. DotSuite — built for the shadows 👻 Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# ❌ Breaks in bundled builds base = os.path.dirname(__file__) qss_path = os.path.join(base, "ui", "ghost.qss") # ❌ Breaks in bundled builds base = os.path.dirname(__file__) qss_path = os.path.join(base, "ui", "ghost.qss") # ❌ Breaks in bundled builds base = os.path.dirname(__file__) qss_path = os.path.join(base, "ui", "ghost.qss") Source mode: __file__ → /home/user/DotGhostBoard/core/app.py ✅ Frozen mode: __file__ → /tmp/_MEI3xk9/core/app.py ✅ ghost.qss → ??? ❌ Source mode: __file__ → /home/user/DotGhostBoard/core/app.py ✅ Frozen mode: __file__ → /tmp/_MEI3xk9/core/app.py ✅ ghost.qss → ??? ❌ Source mode: __file__ → /home/user/DotGhostBoard/core/app.py ✅ Frozen mode: __file__ → /tmp/_MEI3xk9/core/app.py ✅ ghost.qss → ??? ❌ # core/paths.py import os import sys def _base_dir() -> str: """ Detect whether we're running from source or a PyInstaller bundle. In frozen mode, all assets are extracted to sys._MEIPASS at runtime. """ if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): return sys._MEIPASS # PyInstaller extraction folder return os.path.dirname( # two levels up from core/paths.py os.path.dirname(os.path.abspath(__file__)) ) def resource_path(*parts: str) -> str: """ Build an absolute path to any bundled asset, regardless of runtime mode. Usage: resource_path("ui", "ghost.qss") → /tmp/_MEIxxxx/ui/ghost.qss resource_path("data", "icon.png") → /home/user/Project/data/icon.png """ return os.path.join(_base_dir(), *parts) # core/paths.py import os import sys def _base_dir() -> str: """ Detect whether we're running from source or a PyInstaller bundle. In frozen mode, all assets are extracted to sys._MEIPASS at runtime. """ if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): return sys._MEIPASS # PyInstaller extraction folder return os.path.dirname( # two levels up from core/paths.py os.path.dirname(os.path.abspath(__file__)) ) def resource_path(*parts: str) -> str: """ Build an absolute path to any bundled asset, regardless of runtime mode. Usage: resource_path("ui", "ghost.qss") → /tmp/_MEIxxxx/ui/ghost.qss resource_path("data", "icon.png") → /home/user/Project/data/icon.png """ return os.path.join(_base_dir(), *parts) # core/paths.py import os import sys def _base_dir() -> str: """ Detect whether we're running from source or a PyInstaller bundle. In frozen mode, all assets are extracted to sys._MEIPASS at runtime. """ if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): return sys._MEIPASS # PyInstaller extraction folder return os.path.dirname( # two levels up from core/paths.py os.path.dirname(os.path.abspath(__file__)) ) def resource_path(*parts: str) -> str: """ Build an absolute path to any bundled asset, regardless of runtime mode. Usage: resource_path("ui", "ghost.qss") → /tmp/_MEIxxxx/ui/ghost.qss resource_path("data", "icon.png") → /home/user/Project/data/icon.png """ return os.path.join(_base_dir(), *parts) App requests: resource_path("ui", "ghost.qss") │ is_frozen? / \ Yes No │ │ sys._MEIPASS Project Root │ │ /tmp/_MEIxxxx/ui/ghost.qss /home/user/.../ui/ghost.qss App requests: resource_path("ui", "ghost.qss") │ is_frozen? / \ Yes No │ │ sys._MEIPASS Project Root │ │ /tmp/_MEIxxxx/ui/ghost.qss /home/user/.../ui/ghost.qss App requests: resource_path("ui", "ghost.qss") │ is_frozen? / \ Yes No │ │ sys._MEIPASS Project Root │ │ /tmp/_MEIxxxx/ui/ghost.qss /home/user/.../ui/ghost.qss # ❌ v1.5.1 — ghost.qss never made it into the bundle - name: Build run: | pyinstaller --noconsole --onedir \ --add-data "data:data" \ --hidden-import "PyQt6.sip" \ --name dotghostboard main.py # ❌ v1.5.1 — ghost.qss never made it into the bundle - name: Build run: | pyinstaller --noconsole --onedir \ --add-data "data:data" \ --hidden-import "PyQt6.sip" \ --name dotghostboard main.py # ❌ v1.5.1 — ghost.qss never made it into the bundle - name: Build run: | pyinstaller --noconsole --onedir \ --add-data "data:data" \ --hidden-import "PyQt6.sip" \ --name dotghostboard main.py # ✅ v1.5.3 — explicitly map the UI directory - name: Build run: | pyinstaller --noconsole --onedir \ --add-data "data:data" \ --add-data "ui/ghost.qss:ui" \ # ← this line was the missing piece --hidden-import "PyQt6.sip" \ --hidden-import "cryptography" \ --hidden-import "cryptography.hazmat.primitives.asymmetric.x25519" \ --hidden-import "cryptography.hazmat.primitives.ciphers.aead" \ --name dotghostboard main.py # ✅ v1.5.3 — explicitly map the UI directory - name: Build run: | pyinstaller --noconsole --onedir \ --add-data "data:data" \ --add-data "ui/ghost.qss:ui" \ # ← this line was the missing piece --hidden-import "PyQt6.sip" \ --hidden-import "cryptography" \ --hidden-import "cryptography.hazmat.primitives.asymmetric.x25519" \ --hidden-import "cryptography.hazmat.primitives.ciphers.aead" \ --name dotghostboard main.py # ✅ v1.5.3 — explicitly map the UI directory - name: Build run: | pyinstaller --noconsole --onedir \ --add-data "data:data" \ --add-data "ui/ghost.qss:ui" \ # ← this line was the missing piece --hidden-import "PyQt6.sip" \ --hidden-import "cryptography" \ --hidden-import "cryptography.hazmat.primitives.asymmetric.x25519" \ --hidden-import "cryptography.hazmat.primitives.ciphers.aead" \ --name dotghostboard main.py dpkg -c dotghostboard_1.5.3_amd64.deb | grep ui/ # Expected: ... _internal/ui/ghost.qss dpkg -c dotghostboard_1.5.3_amd64.deb | grep ui/ # Expected: ... _internal/ui/ghost.qss dpkg -c dotghostboard_1.5.3_amd64.deb | grep ui/ # Expected: ... _internal/ui/ghost.qss # ❌ v1.5.1 download_path = f"/tmp/dotghostboard_{version}_amd64.deb" # ❌ v1.5.1 download_path = f"/tmp/dotghostboard_{version}_amd64.deb" # ❌ v1.5.1 download_path = f"/tmp/dotghostboard_{version}_amd64.deb" # ✅ v1.5.2+ import os def get_update_staging_dir() -> str: """ Returns a user-specific, restricted directory for staging -weight: 500;">update packages. Created with mode 0o700 — only the owning user can read or write. """ path = os.path.join( os.path.expanduser("~"), ".local", "share", "dotghostboard", "updates" ) os.makedirs(path, mode=0o700, exist_ok=True) return path SAFE_PATH = os.path.join(get_update_staging_dir(), f"dotghostboard_{version}_amd64.deb") # ✅ v1.5.2+ import os def get_update_staging_dir() -> str: """ Returns a user-specific, restricted directory for staging -weight: 500;">update packages. Created with mode 0o700 — only the owning user can read or write. """ path = os.path.join( os.path.expanduser("~"), ".local", "share", "dotghostboard", "updates" ) os.makedirs(path, mode=0o700, exist_ok=True) return path SAFE_PATH = os.path.join(get_update_staging_dir(), f"dotghostboard_{version}_amd64.deb") # ✅ v1.5.2+ import os def get_update_staging_dir() -> str: """ Returns a user-specific, restricted directory for staging -weight: 500;">update packages. Created with mode 0o700 — only the owning user can read or write. """ path = os.path.join( os.path.expanduser("~"), ".local", "share", "dotghostboard", "updates" ) os.makedirs(path, mode=0o700, exist_ok=True) return path SAFE_PATH = os.path.join(get_update_staging_dir(), f"dotghostboard_{version}_amd64.deb") #!/bin/sh # dotghostboard_install.sh — self-destructs after use SAFE_PATH="$1" dpkg -i "$SAFE_PATH" # Install the package rm -f "$SAFE_PATH" # Wipe the .deb from disk rm -f "$0" # Wipe this script itself #!/bin/sh # dotghostboard_install.sh — self-destructs after use SAFE_PATH="$1" dpkg -i "$SAFE_PATH" # Install the package rm -f "$SAFE_PATH" # Wipe the .deb from disk rm -f "$0" # Wipe this script itself #!/bin/sh # dotghostboard_install.sh — self-destructs after use SAFE_PATH="$1" dpkg -i "$SAFE_PATH" # Install the package rm -f "$SAFE_PATH" # Wipe the .deb from disk rm -f "$0" # Wipe this script itself #!/bin/sh # DEBIAN/postinst set -e fix_pkexec() { PKEXEC="/usr/bin/pkexec" if [ -f "$PKEXEC" ]; then CURRENT_PERMS=$(stat -c "%a" "$PKEXEC") if [ "$CURRENT_PERMS" != "4755" ]; then echo "dotghostboard: repairing pkexec SUID bit ($CURRENT_PERMS → 4755)" chmod 4755 "$PKEXEC" fi fi } case "$1" in configure) fix_pkexec ;; esac exit 0 #!/bin/sh # DEBIAN/postinst set -e fix_pkexec() { PKEXEC="/usr/bin/pkexec" if [ -f "$PKEXEC" ]; then CURRENT_PERMS=$(stat -c "%a" "$PKEXEC") if [ "$CURRENT_PERMS" != "4755" ]; then echo "dotghostboard: repairing pkexec SUID bit ($CURRENT_PERMS → 4755)" chmod 4755 "$PKEXEC" fi fi } case "$1" in configure) fix_pkexec ;; esac exit 0 #!/bin/sh # DEBIAN/postinst set -e fix_pkexec() { PKEXEC="/usr/bin/pkexec" if [ -f "$PKEXEC" ]; then CURRENT_PERMS=$(stat -c "%a" "$PKEXEC") if [ "$CURRENT_PERMS" != "4755" ]; then echo "dotghostboard: repairing pkexec SUID bit ($CURRENT_PERMS → 4755)" chmod 4755 "$PKEXEC" fi fi } case "$1" in configure) fix_pkexec ;; esac exit 0 dpkg -i dotghostboard_1.5.3_amd64.deb │ └── postinst runs as root │ /usr/bin/pkexec exists? │ current perms == 4755? / \ Yes No │ │ Skip chmod 4755 │ "Download & Install" works dpkg -i dotghostboard_1.5.3_amd64.deb │ └── postinst runs as root │ /usr/bin/pkexec exists? │ current perms == 4755? / \ Yes No │ │ Skip chmod 4755 │ "Download & Install" works dpkg -i dotghostboard_1.5.3_amd64.deb │ └── postinst runs as root │ /usr/bin/pkexec exists? │ current perms == 4755? / \ Yes No │ │ Skip chmod 4755 │ "Download & Install" works # ui/update_log_screen.py from PyQt6.QtCore import QThread, pyqtSignal import subprocess class InstallWorker(QThread): log_line = pyqtSignal(str) # emitted for every line of dpkg output finished = pyqtSignal(int) # exit code def __init__(self, script_path: str): super().__init__() self.script_path = script_path def run(self): process = subprocess.Popen( ["bash", self.script_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, # merge stderr into stdout text=True, bufsize=1 # line-buffered ) for line in process.stdout: self.log_line.emit(line.rstrip()) process.wait() self.finished.emit(process.returncode) # ui/update_log_screen.py from PyQt6.QtCore import QThread, pyqtSignal import subprocess class InstallWorker(QThread): log_line = pyqtSignal(str) # emitted for every line of dpkg output finished = pyqtSignal(int) # exit code def __init__(self, script_path: str): super().__init__() self.script_path = script_path def run(self): process = subprocess.Popen( ["bash", self.script_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, # merge stderr into stdout text=True, bufsize=1 # line-buffered ) for line in process.stdout: self.log_line.emit(line.rstrip()) process.wait() self.finished.emit(process.returncode) # ui/update_log_screen.py from PyQt6.QtCore import QThread, pyqtSignal import subprocess class InstallWorker(QThread): log_line = pyqtSignal(str) # emitted for every line of dpkg output finished = pyqtSignal(int) # exit code def __init__(self, script_path: str): super().__init__() self.script_path = script_path def run(self): process = subprocess.Popen( ["bash", self.script_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, # merge stderr into stdout text=True, bufsize=1 # line-buffered ) for line in process.stdout: self.log_line.emit(line.rstrip()) process.wait() self.finished.emit(process.returncode) # Color mapping — Regex-based, no AI required import re from PyQt6.QtGui import QColor COLOR_MAP = [ (re.compile(r'\berror\b', re.I), "#FF5555"), # red (re.compile(r'\bwarning\b', re.I), "#FFB86C"), # amber (re.compile(r'\bSetting up\b|\bProcessing\b'), "#50FA7B"), # green (re.compile(r'^--'), "#8BE9FD"), # cyan for steps ] def colorize(line: str) -> str: for pattern, color in COLOR_MAP: if pattern.search(line): return f'<span style="color:{color}">{line}</span>' return line # default — white def on_log_line(self, line: str): self.log_box.append(colorize(line)) # Auto-scroll to bottom sb = self.log_box.verticalScrollBar() sb.setValue(sb.maximum()) # Color mapping — Regex-based, no AI required import re from PyQt6.QtGui import QColor COLOR_MAP = [ (re.compile(r'\berror\b', re.I), "#FF5555"), # red (re.compile(r'\bwarning\b', re.I), "#FFB86C"), # amber (re.compile(r'\bSetting up\b|\bProcessing\b'), "#50FA7B"), # green (re.compile(r'^--'), "#8BE9FD"), # cyan for steps ] def colorize(line: str) -> str: for pattern, color in COLOR_MAP: if pattern.search(line): return f'<span style="color:{color}">{line}</span>' return line # default — white def on_log_line(self, line: str): self.log_box.append(colorize(line)) # Auto-scroll to bottom sb = self.log_box.verticalScrollBar() sb.setValue(sb.maximum()) # Color mapping — Regex-based, no AI required import re from PyQt6.QtGui import QColor COLOR_MAP = [ (re.compile(r'\berror\b', re.I), "#FF5555"), # red (re.compile(r'\bwarning\b', re.I), "#FFB86C"), # amber (re.compile(r'\bSetting up\b|\bProcessing\b'), "#50FA7B"), # green (re.compile(r'^--'), "#8BE9FD"), # cyan for steps ] def colorize(line: str) -> str: for pattern, color in COLOR_MAP: if pattern.search(line): return f'<span style="color:{color}">{line}</span>' return line # default — white def on_log_line(self, line: str): self.log_box.append(colorize(line)) # Auto-scroll to bottom sb = self.log_box.verticalScrollBar() sb.setValue(sb.maximum()) self._cursor_visible = True self._cursor_timer = QTimer() self._cursor_timer.timeout.connect(self._blink_cursor) self._cursor_timer.-weight: 500;">start(500) # 500ms interval def _blink_cursor(self): self._cursor_visible = not self._cursor_visible self.cursor_label.setText("█" if self._cursor_visible else " ") self._cursor_visible = True self._cursor_timer = QTimer() self._cursor_timer.timeout.connect(self._blink_cursor) self._cursor_timer.-weight: 500;">start(500) # 500ms interval def _blink_cursor(self): self._cursor_visible = not self._cursor_visible self.cursor_label.setText("█" if self._cursor_visible else " ") self._cursor_visible = True self._cursor_timer = QTimer() self._cursor_timer.timeout.connect(self._blink_cursor) self._cursor_timer.-weight: 500;">start(500) # 500ms interval def _blink_cursor(self): self._cursor_visible = not self._cursor_visible self.cursor_label.setText("█" if self._cursor_visible else " ") # 1. Confirm ghost.qss is inside the bundle dpkg -c dotghostboard_1.5.3_amd64.deb | grep ui/ # Expected: ./opt/dotghostboard/_internal/ui/ghost.qss # 2. Verify SUID bit post--weight: 500;">install ls -l /usr/bin/pkexec # Expected: -rwsr-xr-x (the 's' in position 4 is the SUID bit) # 3. Test resource_path in frozen mode (AppImage) ./DotGhostBoard-1.5.3-x86_64.AppImage --debug-paths # Should print resolved paths for ghost.qss and icon_256.png # 1. Confirm ghost.qss is inside the bundle dpkg -c dotghostboard_1.5.3_amd64.deb | grep ui/ # Expected: ./opt/dotghostboard/_internal/ui/ghost.qss # 2. Verify SUID bit post--weight: 500;">install ls -l /usr/bin/pkexec # Expected: -rwsr-xr-x (the 's' in position 4 is the SUID bit) # 3. Test resource_path in frozen mode (AppImage) ./DotGhostBoard-1.5.3-x86_64.AppImage --debug-paths # Should print resolved paths for ghost.qss and icon_256.png # 1. Confirm ghost.qss is inside the bundle dpkg -c dotghostboard_1.5.3_amd64.deb | grep ui/ # Expected: ./opt/dotghostboard/_internal/ui/ghost.qss # 2. Verify SUID bit post--weight: 500;">install ls -l /usr/bin/pkexec # Expected: -rwsr-xr-x (the 's' in position 4 is the SUID bit) # 3. Test resource_path in frozen mode (AppImage) ./DotGhostBoard-1.5.3-x86_64.AppImage --debug-paths # Should print resolved paths for ghost.qss and icon_256.png - Isolated vault.db — separate file, separate connection, locked when idle - Pattern-based secret detection — JWT, AWS keys (AKIA...), GitHub tokens (ghp_...), high-entropy hex — shape-based, not keyword-based - Auto-clear: wipes clipboard 30 seconds after a Vault paste - Paranoia Mode: suspends all DB writes on demand