From e12e4c71855c1314f528b7fdf833786c6c06630a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:47:10 +0100 Subject: [PATCH 1/4] fix(launcher): regenerate nqc launcher with the XDG pid/log ladder The launcher kept its pid and log in /tmp under a predictable name, so another local user could pre-create or symlink the pid file and choose which PID `--stop` kills (CWE-377). The /tmp paths came from explicit pid-file/log-file overrides in nqc.launcher.a2ml. This commit deletes those two override lines so the generator's default applies, then regenerates the launcher with `launch-scaffolder realign`, built from launch-scaffolder origin/main 2cb0f24 with --standard standards/launcher-standard_praxis.deed: PID ${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/nqc/server.pid LOG ${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/nqc/server.log The rest of the launcher diff is the generator's current canonical output. It includes the metadata block moving to @launcher-deed (standard-version 0.4.0), ensure_state_dirs plus a private-dir check, PID validation before kill, atomic desktop-integration writes, and shellcheck-clean output. nqc/Justfile's `clean` recipe removed /tmp/nqc.pid and /tmp/nqc.log; it now removes the same files at their new locations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136eszqrQ53Kj7aBH1D4rXK --- nqc/Justfile | 2 +- nqc/nqc-launcher.sh | 342 ++++++++++++++++++++++++++++++++++-------- nqc/nqc.launcher.a2ml | 2 - 3 files changed, 279 insertions(+), 67 deletions(-) diff --git a/nqc/Justfile b/nqc/Justfile index 9afe3e88..d1132bca 100644 --- a/nqc/Justfile +++ b/nqc/Justfile @@ -40,7 +40,7 @@ uninstall: # Clean build artifacts clean: rm -rf web/node_modules web/.deno web/dist - rm -f /tmp/nqc.pid /tmp/nqc.log + rm -f "${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/nqc/server.pid" "${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/nqc/server.log" # Run tests test: diff --git a/nqc/nqc-launcher.sh b/nqc/nqc-launcher.sh index 1b93db15..8f93232c 100755 --- a/nqc/nqc-launcher.sh +++ b/nqc/nqc-launcher.sh @@ -1,25 +1,27 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 +# SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # -# @a2ml-metadata begin -# ( -# id = "nqc-launcher" -# type = "launcher" -# version = "1.0.0" -# app-name = "nqc" -# app-display = "NQC" -# app-url = "" -# runtime-kind = "process" -# standards-compliance = [ -# "launcher-standard.adoc" -# "LM-LA-LIFECYCLE-STANDARD.adoc" -# "cross-platform-system-integration-modes" -# ] -# standard-spec-version = "0.2.0" -# generator = "launch-scaffolder" -# ) -# @a2ml-metadata end +# @launcher-deed begin +# ;; SPDX-License-Identifier: MPL-2.0 +# (praxis-deed +# :schema-version "1.0.0" +# :canonical-name "nqc-launcher" +# :beholding-chora #u5"estate/chora" +# (artefact :type "launcher" :version "1.0.0" +# :generator "launch-scaffolder") +# (app :name "nqc" :display "NQC" +# :url "" :runtime-kind "process") +# (compliance :standard-version "0.4.0" +# :standards ("launcher-standard.adoc" +# "LM-LA-LIFECYCLE-STANDARD.adoc" +# "cross-platform-system-integration-modes")) +# (modes :accepted ("--start" "--stop" "--status" "--browser" "--web" "--auto" "--integ" "--disinteg" "--help" "--version")) +# (platforms :supported ("linux" "macos" "windows")) +# (lifecycle-phases :covered ("start" "stop" "status" "integ" "disinteg") +# :deferred ("install" "uninstall" "update" "backup" "restore" "migrate"))) +# @launcher-deed end # # ============================================================================ # nqc-launcher.sh — NQC @@ -36,30 +38,68 @@ set -euo pipefail # CONFIGURATION # ---------------------------------------------------------------------------- -APP_NAME="nqc" -APP_DISPLAY="NQC" -APP_DESC="Non-Quantum Computing — hyperpolymath database client" -APP_CATEGORIES="Development;Database;" -APP_GENERIC_NAME="NQC" -RUNTIME_KIND="process" +APP_NAME='nqc' +APP_DISPLAY='NQC' +APP_DESC='Non-Quantum Computing — hyperpolymath database client' +APP_CATEGORIES='Development;Database;' +APP_GENERIC_NAME='NQC' +APP_VERSION='1.0.0' +BUILD_SHA_SHORT='unknown' +RUNTIME_KIND='process' -REPO_DIR="/var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc" -ICON_SOURCE="/var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc/assets/icon-256.png" +REPO_DIR='/var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc' +ICON_SOURCE='/var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc/assets/icon-256.png' # Absolute path back to the per-app `.launcher.a2ml` config that # produced this script. Consumed by the --integ / --disinteg arms when # the `launch-scaffolder` binary is on $PATH, so they can delegate to # the Rust implementation instead of running the shell fallback. -CONFIG_FILE="/var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc/nqc.launcher.a2ml" +CONFIG_FILE='/var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc/nqc.launcher.a2ml' URL="" +PID_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/nqc/server.pid" +LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/nqc/server.log" + +# Both defaults live in per-app directories under per-user XDG state. Create +# the default leaves as 0700 before +# the first write: a predictable path inside a world-writable directory (the +# old /tmp default) let any local user pre-create or symlink the pid file and +# steer what this script later killed or removed (Hypatia 82/83, #48). +# Default locations use unique per-app directories before chmod. Explicit +# paths are never chmodded: a path such as /tmp must never have its parent +# permissions changed. Every resolved parent is checked before PID/log I/O. +ensure_state_dirs() { + local pid_dir log_dir + pid_dir="$(dirname "$PID_FILE")" + log_dir="$(dirname "$LOG_FILE")" + mkdir -p "$pid_dir" "$log_dir" +chmod 0700 "$pid_dir" +chmod 0700 "$log_dir" + check_private_state_dir "$pid_dir" || return 1 + check_private_state_dir "$log_dir" || return 1 +} -PID_FILE="/tmp/nqc.pid" -LOG_FILE="/tmp/nqc.log" +# Refuse shared or group-writable state locations. This also prevents a +# custom /tmp path from causing chmod on /tmp or exposing a predictable PID +# file to other local users. +check_private_state_dir() { + local dir="$1" mode digits numeric + [[ -O "$dir" ]] || { err "State directory is not owned by this user: $dir"; return 1; } + mode="$(stat -c '%a' "$dir" 2>/dev/null || stat -f '%Lp' "$dir" 2>/dev/null)" || { + err "Cannot inspect state-directory permissions: $dir"; return 1; + } + digits="${mode: -3}" + [[ "$digits" =~ ^[0-7]{3}$ ]] || { err "Cannot inspect state-directory permissions: $dir"; return 1; } + numeric=$((8#$digits)) + if (( numeric & 022 )); then + err "State directory is group/world-writable; choose a private location: $dir" + return 1 + fi +} # Explicit argv from [runtime].command -START_COMMAND=(/home/hyper/.bin/nqc --gui ) +START_COMMAND=('/home/hyper/.bin/nqc' '--gui') MODE="${1:---start}" FORCE="false" @@ -81,7 +121,10 @@ gui_error() { local title="$1" local body="$2" err "$title" - echo "$body" | sed 's/^/ /' >&2 + # ${body//…} rather than `echo "$body" | sed 's/^/ /'` (shellcheck SC2001): + # the same two-space indent on every line, including empty ones, with no + # subprocess per call. + printf ' %s\n' "${body//$'\n'/$'\n '}" >&2 if is_gui_context; then if command -v kdialog >/dev/null 2>&1; then kdialog --title "$APP_DISPLAY: $title" --error "$body" 2>/dev/null & elif command -v zenity >/dev/null 2>&1; then zenity --error --title="$APP_DISPLAY: $title" --text="$body" --width=500 2>/dev/null & @@ -114,6 +157,7 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$APPS_DIR/${APP_NAME}.desktop" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_NAME}.desktop" ICON_TARGET="$ICON_DIR/${APP_NAME}.png" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; macos) @@ -123,6 +167,7 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$APPS_DIR/${APP_DISPLAY}.app" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_DISPLAY}.command" ICON_TARGET="$APPS_DIR/${APP_DISPLAY}.app/Contents/Resources/icon.png" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; windows) @@ -133,11 +178,12 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$START_MENU_DIR/${APP_DISPLAY}.lnk" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_DISPLAY}.lnk" ICON_TARGET="$BIN_DIR/${APP_NAME}.ico" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher.sh" ;; *) APPS_DIR=""; DESKTOP_SHORTCUT_DIR=""; BIN_DIR="$HOME/.local/bin" - DESKTOP_FILE_TARGET=""; DESKTOP_SHORTCUT_TARGET=""; ICON_TARGET="" + DESKTOP_FILE_TARGET=""; DESKTOP_SHORTCUT_TARGET=""; ICON_TARGET=""; ICON_MARKER_TARGET="" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; esac @@ -146,12 +192,34 @@ esac # PROCESS MANAGEMENT # ---------------------------------------------------------------------------- +pid_directory_is_safe() { + local pid_dir + pid_dir="$(dirname "$PID_FILE")" + [ -d "$pid_dir" ] || return 0 + check_private_state_dir "$pid_dir" +} + +read_pid() { + local pid + IFS= read -r pid < "$PID_FILE" || return 1 + if [[ ! "$pid" =~ ^[0-9]{1,10}$ ]] || (( 10#$pid < 2 )); then + err "Invalid PID value in $PID_FILE" + return 1 + fi + printf '%s' "$pid" +} + is_running() { - [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null + [ -f "$PID_FILE" ] || return 1 + pid_directory_is_safe || return 1 + local pid + pid="$(read_pid)" || return 1 + kill -0 "$pid" 2>/dev/null } clear_stale_pid() { - if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + if [ -f "$PID_FILE" ] && ! is_running; then + warn "Removing stale or invalid PID file" rm -f "$PID_FILE" fi } @@ -159,9 +227,10 @@ clear_stale_pid() { start_server() { + ensure_state_dirs clear_stale_pid if is_running; then - log "Already running (PID $(cat "$PID_FILE"))" + log "Already running (PID $(read_pid))" return 0 fi @@ -180,13 +249,15 @@ nohup "${START_COMMAND[@]}" >"$LOG_FILE" 2>&1 & echo $! > "$PID_FILE" sleep 0.2 - if ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + local started_pid + started_pid="$(read_pid)" || { rm -f "$PID_FILE"; return 1; } + if ! kill -0 "$started_pid" 2>/dev/null; then gui_error "Process exited immediately" "Check $LOG_FILE" rm -f "$PID_FILE" return 1 fi -log "Started (PID $(cat "$PID_FILE"))" +log "Started (PID $(read_pid))" return 0 } @@ -196,7 +267,10 @@ if ! is_running; then return 0 fi log "Stopping $APP_DISPLAY..." - kill "$(cat "$PID_FILE")" 2>/dev/null || true + pid_directory_is_safe || return 1 + local pid + pid="$(read_pid)" || return 1 + kill "$pid" 2>/dev/null || true rm -f "$PID_FILE" log "Stopped" } @@ -227,12 +301,96 @@ open_browser() { # SYSTEM INTEGRATION — --integ / --disinteg # ---------------------------------------------------------------------------- +path_exists() { + [ -e "$1" ] || [ -L "$1" ] +} + already_integrated() { - [ -f "$DESKTOP_FILE_TARGET" ] || [ -f "$LAUNCHER_TARGET" ] + path_exists "$DESKTOP_FILE_TARGET" || path_exists "$DESKTOP_SHORTCUT_TARGET" || \ + path_exists "$ICON_TARGET" || path_exists "$ICON_MARKER_TARGET" || path_exists "$LAUNCHER_TARGET" +} + +is_managed_install() { + local found_marker="false" target + local marker_targets=("$LAUNCHER_TARGET" "$DESKTOP_FILE_TARGET" "$DESKTOP_SHORTCUT_TARGET") + for target in "${marker_targets[@]}"; do + if ! path_exists "$target"; then + continue + fi + if [ "$target" = "$LAUNCHER_TARGET" ]; then + if [ ! -f "$target" ] || ! grep -Eq '^# GENERATED by launch-scaffolder from .+' "$target" 2>/dev/null; then + return 1 + fi + else + if [ ! -f "$target" ] || ! grep -Fxq '# X-Launch-Scaffolder=launch-scaffolder' "$target" 2>/dev/null; then + return 1 + fi + fi + found_marker="true" + done + if path_exists "$ICON_TARGET"; then + if [ ! -f "$ICON_MARKER_TARGET" ] || ! grep -Fxq 'launch-scaffolder managed icon' "$ICON_MARKER_TARGET" 2>/dev/null; then + return 1 + fi + found_marker="true" + fi + if path_exists "$ICON_MARKER_TARGET"; then + if [ ! -f "$ICON_MARKER_TARGET" ] || ! grep -Fxq 'launch-scaffolder managed icon' "$ICON_MARKER_TARGET" 2>/dev/null; then + return 1 + fi + found_marker="true" + fi + [ "$found_marker" = "true" ] +} + +atomic_write_icon_marker() { + local temp + if ! temp="$(mktemp "${ICON_MARKER_TARGET}.tmp.XXXXXX")"; then + err "cannot create temporary icon ownership marker" + return 1 + fi + if ! printf '%s\n' 'launch-scaffolder managed icon' >"$temp" || \ + ! chmod 0644 "$temp" || ! mv -f "$temp" "$ICON_MARKER_TARGET"; then + rm -f "$temp" + err "cannot atomically write icon ownership marker" + return 1 + fi +} + +atomic_copy() { + local source="$1" target="$2" mode="$3" temp + if ! temp="$(mktemp "${target}.tmp.XXXXXX")"; then + err "cannot create temporary file beside $target" + return 1 + fi + if ! cp "$source" "$temp" || ! chmod "$mode" "$temp" || ! mv -f "$temp" "$target"; then + rm -f "$temp" + err "cannot atomically install $target" + return 1 + fi +} + +desktop_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" +} + +desktop_exec_arg() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//\`/\\\`}" + value="${value//\$/\\\$}" + value="${value//%/%%}" + printf '"%s"\n' "$value" } write_linux_desktop_file() { - local target="$1" + local target="$1" temp local icon_name if [ -f "$ICON_TARGET" ]; then icon_name="$APP_NAME" @@ -243,20 +401,27 @@ write_linux_desktop_file() { # keepopen.sh implements the standard fallback ladder: GUI → TUI → # bash-at-repo-root. See launcher-standard.adoc §Fallback Ladder. local keepopen="/var/mnt/eclipse/repos/.desktop-tools/keepopen.sh" - local gui_cmd tui_cmd + local gui_cmd tui_cmd quoted_launcher quoted_log + printf -v quoted_launcher '%q' "$LAUNCHER_TARGET" + printf -v quoted_log '%q' "$LOG_FILE" # process: GUI = start then tail log so terminal stays open; # TUI = just tail the existing log; Shell = repo root. - gui_cmd="$LAUNCHER_TARGET --start && tail -f $LOG_FILE" - tui_cmd="tail -n 200 -f $LOG_FILE" + gui_cmd="$quoted_launcher --start && tail -f $quoted_log" + tui_cmd="tail -n 200 -f $quoted_log" - cat > "$target" < "$temp" </dev/null || true fi if [ -x "/var/mnt/eclipse/repos/.desktop-tools/verify-desktop-integrity.sh" ]; then - /var/mnt/eclipse/repos/.desktop-tools/verify-desktop-integrity.sh --generate 2>/dev/null \ - && log " + integrity hashes generated" \ - || log " · integrity hash generation failed (non-fatal)" + # if/else, not `cmd && log || log`: in that form a FAILING log on the + # success branch also fires the failure branch, so a run that worked + # reports both "generated" and "generation failed". The command's own + # status is what should choose the message. + if /var/mnt/eclipse/repos/.desktop-tools/verify-desktop-integrity.sh --generate 2>/dev/null; then + log " + integrity hashes generated" + else + log " · integrity hash generation failed (non-fatal)" + fi fi } @@ -320,10 +510,16 @@ do_integ() { exec "${forward[@]}" fi - if already_integrated && [ "$FORCE" != "true" ]; then - warn "$APP_DISPLAY is already integrated." - read -rp "Reinstall? [y/N] " confirm - [[ ! "$confirm" =~ ^[Yy]$ ]] && { log "Nothing changed."; return 0; } + if already_integrated; then + if ! is_managed_install; then + err "refusing to overwrite unmarked integration files for $APP_NAME" + return 1 + fi + if [ "$FORCE" != "true" ]; then + warn "$APP_DISPLAY is already integrated." + read -rp "Reinstall? [y/N] " confirm + [[ ! "$confirm" =~ ^[Yy]$ ]] && { log "Nothing changed."; return 0; } + fi fi log "Integrating $APP_DISPLAY with the $PLATFORM desktop..." case "$PLATFORM" in @@ -334,6 +530,7 @@ do_integ() { } do_disinteg() { + ensure_state_dirs || return 1 # Fast path: delegate to `launch-scaffolder provision --disinteg` # when available. Stop any running process first so the binary # doesn't have to re-implement the process-management arm. @@ -346,18 +543,23 @@ if is_running; then exec launch-scaffolder provision --disinteg "$CONFIG_FILE" --no-confirm fi + if already_integrated && ! is_managed_install; then + err "refusing to remove unmarked integration files for $APP_NAME" + return 1 + fi log "Removing $APP_DISPLAY system integration..." local removed_anything="false" local targets=( "$DESKTOP_FILE_TARGET" "$DESKTOP_SHORTCUT_TARGET" "$ICON_TARGET" + "$ICON_MARKER_TARGET" "$LAUNCHER_TARGET" ) for t in "${targets[@]}"; do [ -z "$t" ] && continue if [ -e "$t" ] || [ -L "$t" ]; then - rm -rf "$t" + rm -f "$t" log " - removed $t" removed_anything="true" fi @@ -391,6 +593,9 @@ System integration: Misc: --help This text + --version Print the machine-readable launcher version + --browser Alias for --auto + --web Alias for --auto Detected platform: $PLATFORM Runtime kind: $RUNTIME_KIND @@ -402,6 +607,12 @@ EOF # MAIN SWITCH # ---------------------------------------------------------------------------- +platform_id() { + local arch + arch="$(uname -m)" + printf '%s-%s' "$PLATFORM" "$arch" +} + case "$MODE" in --start) start_server ;; --stop) stop_server ;; @@ -413,7 +624,10 @@ case "$MODE" in fi ;; --browser|--web) -log "$APP_DISPLAY has no URL — --browser is not applicable" +start_server + ;; + --version) + printf '%s %s (%s) [%s]\n' "$APP_NAME" "$APP_VERSION" "$BUILD_SHA_SHORT" "$(platform_id)" ;; --auto) start_server diff --git a/nqc/nqc.launcher.a2ml b/nqc/nqc.launcher.a2ml index 98613058..d8b8d8da 100644 --- a/nqc/nqc.launcher.a2ml +++ b/nqc/nqc.launcher.a2ml @@ -21,8 +21,6 @@ path = "/var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc" [runtime] kind = "process" command = ["/home/hyper/.bin/nqc", "--gui"] -pid-file = "/tmp/nqc.pid" -log-file = "/tmp/nqc.log" [icon] source = "{repo-dir}/assets/icon-256.png" From f698c916546b223c1a7729fca31c28615c8de34f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:08:28 +0100 Subject: [PATCH 2/4] Update nqc/nqc-launcher.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- nqc/nqc-launcher.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nqc/nqc-launcher.sh b/nqc/nqc-launcher.sh index 8f93232c..22750515 100755 --- a/nqc/nqc-launcher.sh +++ b/nqc/nqc-launcher.sh @@ -227,7 +227,7 @@ clear_stale_pid() { start_server() { - ensure_state_dirs + ensure_state_dirs || return 1 clear_stale_pid if is_running; then log "Already running (PID $(read_pid))" From 26a26390a23cbfaae267e1de756a961244698601 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:12:22 +0000 Subject: [PATCH 3/4] docs(launcher): document NQC launcher helper behavior and safety preconditions --- nqc/nqc-launcher.sh | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/nqc/nqc-launcher.sh b/nqc/nqc-launcher.sh index 22750515..62f2bbb1 100755 --- a/nqc/nqc-launcher.sh +++ b/nqc/nqc-launcher.sh @@ -117,6 +117,7 @@ is_gui_context() { [ ! -t 2 ] && { [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; } } +# Print $1 (title) and $2 (body) to stderr and, when available, a GUI dialog. gui_error() { local title="$1" local body="$2" @@ -192,6 +193,7 @@ esac # PROCESS MANAGEMENT # ---------------------------------------------------------------------------- +# Succeed if the PID directory is absent or passes the private-state checks. pid_directory_is_safe() { local pid_dir pid_dir="$(dirname "$PID_FILE")" @@ -199,6 +201,7 @@ pid_directory_is_safe() { check_private_state_dir "$pid_dir" } +# Print the PID from PID_FILE; fail if unreadable, malformed, or less than 2. read_pid() { local pid IFS= read -r pid < "$PID_FILE" || return 1 @@ -209,6 +212,7 @@ read_pid() { printf '%s' "$pid" } +# Succeed when a safe PID file contains a valid PID reachable with kill -0. is_running() { [ -f "$PID_FILE" ] || return 1 pid_directory_is_safe || return 1 @@ -217,6 +221,8 @@ is_running() { kill -0 "$pid" 2>/dev/null } +# Remove an existing PID file when is_running fails; call only after validating +# the state directory with ensure_state_dirs. clear_stale_pid() { if [ -f "$PID_FILE" ] && ! is_running; then warn "Removing stale or invalid PID file" @@ -226,6 +232,8 @@ clear_stale_pid() { +# Start START_COMMAND with output in LOG_FILE and its PID in PID_FILE. +# Return success if already running, or failure if startup validation fails. start_server() { ensure_state_dirs || return 1 clear_stale_pid @@ -261,6 +269,7 @@ log "Started (PID $(read_pid))" return 0 } +# Signal the validated running PID and remove PID_FILE; succeed if not running. stop_server() { if ! is_running; then log "No running instance found" @@ -301,15 +310,19 @@ open_browser() { # SYSTEM INTEGRATION — --integ / --disinteg # ---------------------------------------------------------------------------- +# Succeed if $1 exists, including when it is a dangling symbolic link. path_exists() { [ -e "$1" ] || [ -L "$1" ] } +# Succeed if any configured desktop, icon, ownership marker, or launcher exists. already_integrated() { path_exists "$DESKTOP_FILE_TARGET" || path_exists "$DESKTOP_SHORTCUT_TARGET" || \ path_exists "$ICON_TARGET" || path_exists "$ICON_MARKER_TARGET" || path_exists "$LAUNCHER_TARGET" } +# Succeed if at least one integration artifact exists and all existing artifacts +# have the required launch-scaffolder ownership markers. is_managed_install() { local found_marker="false" target local marker_targets=("$LAUNCHER_TARGET" "$DESKTOP_FILE_TARGET" "$DESKTOP_SHORTCUT_TARGET") @@ -343,6 +356,8 @@ is_managed_install() { [ "$found_marker" = "true" ] } +# Atomically install the icon ownership marker at ICON_MARKER_TARGET as 0644. +# Return failure and clean up the temporary file if installation fails. atomic_write_icon_marker() { local temp if ! temp="$(mktemp "${ICON_MARKER_TARGET}.tmp.XXXXXX")"; then @@ -357,6 +372,8 @@ atomic_write_icon_marker() { fi } +# Copy $1 (source) to $2 (target) with $3 (mode) via a temporary sibling file. +# Atomically replace the target, or clean up the temporary file on failure. atomic_copy() { local source="$1" target="$2" mode="$3" temp if ! temp="$(mktemp "${target}.tmp.XXXXXX")"; then @@ -370,6 +387,8 @@ atomic_copy() { fi } +# Print $1 with backslashes and newline, carriage-return, and tab characters +# escaped for a desktop-entry string value. desktop_escape() { local value="$1" value="${value//\\/\\\\}" @@ -379,6 +398,8 @@ desktop_escape() { printf '%s' "$value" } +# Print $1 as a quoted desktop-entry Exec argument, escaping reserved characters +# and doubling percent signs to prevent field-code expansion. desktop_exec_arg() { local value="$1" value="${value//\\/\\\\}" @@ -389,6 +410,8 @@ desktop_exec_arg() { printf '"%s"\n' "$value" } +# Atomically write a managed Linux desktop entry to $1 with mode 0644, +# launcher actions, and the configured icon or a system fallback. write_linux_desktop_file() { local target="$1" temp local icon_name @@ -449,6 +472,8 @@ EOF fi } +# Install the launcher, optional icon and marker, menu entry, and desktop +# shortcut on Linux, then refresh available desktop metadata tools. do_integ_linux() { mkdir -p "$APPS_DIR" "$ICON_DIR" "$BIN_DIR" "$DESKTOP_SHORTCUT_DIR" # Declared and assigned separately (shellcheck SC2155). `local x="$(cmd)"` @@ -497,6 +522,8 @@ do_integ_linux() { fi } +# Delegate integration to launch-scaffolder when available; otherwise install +# on Linux, refusing unmarked artifacts and prompting unless FORCE is true. do_integ() { # Fast path: delegate to `launch-scaffolder provision` when it's on # $PATH and the source config is still where it was at mint time. @@ -529,6 +556,8 @@ do_integ() { log "✓ $APP_DISPLAY is now in your menu and on your Desktop." } +# Stop a running process, then delegate removal to launch-scaffolder when +# available; otherwise remove managed integration artifacts and the PID file. do_disinteg() { ensure_state_dirs || return 1 # Fast path: delegate to `launch-scaffolder provision --disinteg` @@ -574,6 +603,7 @@ if is_running; then fi } +# Print launcher usage, supported modes, and the detected runtime configuration. show_help() { cat < Date: Wed, 30 Sep 2026 16:12:52 +0000 Subject: [PATCH 4/4] fix(launcher): preserve existing state directory permissions, reject symlink directories, and use read_pid for status --- nqc/nqc-launcher.sh | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/nqc/nqc-launcher.sh b/nqc/nqc-launcher.sh index 62f2bbb1..b5372a88 100755 --- a/nqc/nqc-launcher.sh +++ b/nqc/nqc-launcher.sh @@ -62,20 +62,17 @@ PID_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaff LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/nqc/server.log" # Both defaults live in per-app directories under per-user XDG state. Create -# the default leaves as 0700 before -# the first write: a predictable path inside a world-writable directory (the -# old /tmp default) let any local user pre-create or symlink the pid file and -# steer what this script later killed or removed (Hypatia 82/83, #48). -# Default locations use unique per-app directories before chmod. Explicit -# paths are never chmodded: a path such as /tmp must never have its parent -# permissions changed. Every resolved parent is checked before PID/log I/O. +# new directories as 0700 before the first write: a predictable path inside a +# world-writable directory (the old /tmp default) let any local user pre-create +# or symlink the pid file and steer what this script later killed or removed +# (Hypatia 82/83, #48). +# Existing directory permissions are never changed. Every resolved parent is +# checked before PID/log I/O. ensure_state_dirs() { local pid_dir log_dir pid_dir="$(dirname "$PID_FILE")" log_dir="$(dirname "$LOG_FILE")" - mkdir -p "$pid_dir" "$log_dir" -chmod 0700 "$pid_dir" -chmod 0700 "$log_dir" + (umask 077; mkdir -p "$pid_dir" "$log_dir") || return 1 check_private_state_dir "$pid_dir" || return 1 check_private_state_dir "$log_dir" || return 1 } @@ -85,6 +82,7 @@ chmod 0700 "$log_dir" # file to other local users. check_private_state_dir() { local dir="$1" mode digits numeric + [[ -d "$dir" && ! -L "$dir" ]] || { err "State path is not a non-symlink directory: $dir"; return 1; } [[ -O "$dir" ]] || { err "State directory is not owned by this user: $dir"; return 1; } mode="$(stat -c '%a' "$dir" 2>/dev/null || stat -f '%Lp' "$dir" 2>/dev/null)" || { err "Cannot inspect state-directory permissions: $dir"; return 1; @@ -649,7 +647,7 @@ case "$MODE" in --stop) stop_server ;; --status) if is_running; then - log "Running (PID $(cat "$PID_FILE"))${URL:+ — $URL}" + log "Running (PID $(read_pid))${URL:+ — $URL}" else log "Not running${URL:+ — $URL}" fi