fix: stabilization pass — fail-closed auth, ai flag validation, lint/config/security hardening, regression tests
gates / consistency-and-conventions (push) Successful in 26s

17-point code-level audit executed via Explorer->Architect->Builder->Tester->Reviewer;
Reviewer accepted (APPROVE_WITH_NOTES; 3 block-list items resolved):

- security: telegram sender-owner AND-gate + TELEGRAM_OWNER_ID, matrix
  MATRIX_ROOM_ID fail-closed, gpg --passphrase-fd 3 (no argv secret),
  /dev/tcp positional-arg form (checkport/smb-client/share-lib/NET_PROBE),
  eval deny-by-default + --no-command-execution carried by both chat bridges,
  tty-gated --trust; config/{telegram,matrix}.env reference templates
- ai: all ExecStart flags validated against installed llama.cpp
  (requested->error, default->omit+warn, CONFIG_REQUESTED_FLAGS); single-file
  hf download failure rc=1 + no .hf-meta; LLAMACPP_HOST coherent;
  POS_SUBCMDS + metadata gaps closed
- tooling: lint-conventions Bash-native rewrite (~24-30x faster, rules and
  output byte-identical, :num restored); pos system uninstall covers all 12
  libs + scale-tail + flags dir + systemd user units (|| true) + plugin
  markers; anchored .bash_completion/.bashrc removal replaces sed -i '/pos/d'
- config: canonical load_env_file in lib/config-ui.sh (CRLF strip, env-wins,
  XDG, LOADED_ENV_KEYS); 9 tools migrated; entertainment-lib collapsed to
  wrappers; docker-compose deliberately unmigrated (source semantics)
- tests: first committed regression suite — tests/run-tests.sh zero-dep
  runner + make test; 12 files / 179 checks / 0 skip / ~52s; hard skip
  contract; systemd-analyze verify on generated unit PASS

Verified: make gen idempotent; make check green; make lint 0 FAIL, 0 WARN;
make test green; bash -n clean; git diff --check clean. Audit deliverables +
agent reports + AGENT_TODO Done entry included.
This commit is contained in:
Your Name
2026-09-06 07:25:44 -04:00
parent 528b16676e
commit d817c37652
69 changed files with 5161 additions and 406 deletions
+40 -41
View File
@@ -1,14 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai ask — AI assistant: ask, chat, sessions, capture, models, providers
# POS_SUBCMDS: ask chat sessions capture models providers
# POS_FLAGS: --provider --model --session --system --full --last --trust
# POS_SUBCMDS: ask chat sessions capture models providers llamacpp
# POS_FLAGS: --provider --model --session --system --full --last --trust --no-command-execution
# POS_CONFIG: ai | ai.env | AI_PROVIDER=:Provider (gemini, openrouter or llamacpp, default gemini) | @[AI_PROVIDER=gemini|] Gemini | *providers=gemini | @[AI_PROVIDER=openrouter] OpenRouter | *providers=openrouter | @[AI_PROVIDER=llamacpp] llamacpp | LLAMACPP_PORT=:Server port (default 8088) | LLAMACPP_HOST=:Bind address (default 127.0.0.1) | LLAMACPP_MODEL=:Default model path (GGUF) | LLAMACPP_CTX_SIZE=num:Context window size (default 4096) | LLAMACPP_GPU_LAYERS=num:GPU layers (-1=auto, 0=CPU, default -1) | LLAMACPP_THREADS=num:CPU threads (default: nproc) | *providers=llamacpp | @General | AI_SYSTEM_PROMPT=:Custom system prompt (overrides built-in, empty to reset)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# ── Paths & constants ──────────────────────────────────────────
CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
CONFIG_FILE="${CONFIG_FILE:-$CONFIG_DIR/ai.env}"
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
@@ -32,12 +35,12 @@ DEFAULT_SYSTEM_PROMPT_HARD="You are a Linux CLI assistant. Rules:
5. Match the user's OS/package manager (apt/dnf/pacman)"
# Legacy: kept for session migration and backward compat config
LEGACY_GEMINI_CONFIG="$HOME/.config/linux_post_install/ai.env"
LEGACY_OPENROUTER_CONFIG="$HOME/.config/linux_post_install/ai-openrouter.env"
LEGACY_GEMINI_CONFIG="${LEGACY_GEMINI_CONFIG:-$CONFIG_DIR/ai.env}"
LEGACY_OPENROUTER_CONFIG="${LEGACY_OPENROUTER_CONFIG:-$CONFIG_DIR/ai-openrouter.env}"
usage() {
cat <<EOF
Usage: pos ai [subcommand] [--provider <name>] [--model <id>] [--session <name>] [--system <text>] [--full] [--last] [--trust]
Usage: pos ai [subcommand] [--provider <name>] [--model <id>] [--session <name>] [--system <text>] [--full] [--last] [--trust] [--no-command-execution]
AI assistant with pluggable providers (gemini, openrouter, llamacpp).
@@ -73,8 +76,16 @@ Options:
'capture'. Notes on stderr which source was attached and
its age; warns when stale (>60 min).
--trust Auto-execute agent-detected commands without confirmation.
Used by trusted alias wrappers — do NOT pass manually
unless you fully trust the agent's output.
Interactive terminals only — it has no effect when stdout/
stdin is not a tty (e.g. chat bridges, cron), which never
execute commands. Used by trusted alias wrappers — do NOT
pass manually unless you fully trust the agent's output.
--no-command-execution Never run agent-detected command blocks. The proposed
command is neither printed nor executed; no
confirmation prompt is shown (structural guard — used by
chat bridges so a future refactor cannot auto-execute).
Each of these two modes contradict the other: the last one
given on the command line wins.
-h|--help This help.
Config: $CONFIG_FILE (edit with 'pos config ai')
@@ -126,36 +137,16 @@ load_provider() {
source "$f"
}
# ── ai.env loader (same pattern as telegram.env) ────────────────
# ── ai.env loader (canonical loader + legacy files) ─────────────
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
v="${v//$'\r'/}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
load_env_file "$CONFIG_FILE"
# Legacy provider-specific config files (fallback for old configs)
# Both files are loaded — env-var precedence means unified AI_API_KEY wins.
local legacy_files="$LEGACY_GEMINI_CONFIG $LEGACY_OPENROUTER_CONFIG"
local legacy_env
for legacy_env in $legacy_files; do
[ -f "$legacy_env" ] && [ "$legacy_env" != "$CONFIG_FILE" ] || continue
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in \#*) continue ;; esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
v="${v//$'\r'/}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$legacy_env" || true)
load_env_file "$legacy_env"
done
}
@@ -378,8 +369,14 @@ _extract_commands() {
# ── Interactive prompt to run extracted commands ─────────────────
_prompt_run_command() {
local cmd="$1" trusted="${2:-0}"
# Only prompt on interactive terminals with a controlling tty
# Structural guards: commands never execute without a controlling tty, and
# never when --no-command-execution was passed. Both apply BEFORE any
# auto-execute path (including --trust), so a future refactor cannot
# introduce a non-interactive or bridge-forced execution.
[ -w /dev/tty ] || return 0
if [ "${NO_EXEC:-0}" -eq 1 ]; then
return 0
fi
printf '\n%s\n' "Command detected:" >&2
printf ' %s\n\n' "$cmd" >&2
if [ "$trusted" -eq 1 ]; then
@@ -388,20 +385,19 @@ _prompt_run_command() {
run eval "$cmd"
return
fi
printf 'Run this command? [Y/n] ' >&2
printf 'Run this command? [y/N] ' >&2
local choice
IFS= read -r choice </dev/tty || choice=""
case "${choice,,}" in
n|N)
case "$choice" in
[yY])
printf '%s\n' "$cmd"
run eval "$cmd"
;;
*)
# Add to shell history so user can press ↑ to recall, edit, run
history -s "$cmd" 2>/dev/null || true
printf '%s\n' "Command added to history — press ↑ to recall, edit, and run." >&2
;;
*)
# Y or Enter: execute
printf '%s\n' "$cmd"
run eval "$cmd"
;;
esac
}
@@ -642,6 +638,7 @@ MODEL_OVERRIDE=""
FULL_MODE=0
LAST_MODE=0
TRUST_MODE=0
NO_EXEC=0
PROVIDER=""
cmd=""
args=()
@@ -665,7 +662,9 @@ while [ $# -gt 0 ]; do
--last)
LAST_MODE=1; shift ;;
--trust)
TRUST_MODE=1; shift ;;
TRUST_MODE=1; NO_EXEC=0; shift ;;
--no-command-execution)
NO_EXEC=1; TRUST_MODE=0; shift ;;
-*) err "Unknown option '$1' (see --help)" ;;
*)
if [ -z "$cmd" ]; then
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai gemini — Forward to pos ai --provider gemini (backward compat)
# POS_SUBCMDS: ask chat models sessions capture
# POS_SUBCMDS: ask chat models sessions capture providers
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/gemini.sh
case "${1:-}" in -h|--help) exec pos ai --provider gemini --help ;; esac
exec pos ai --provider gemini "$@"
+24 -35
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai hf — Download AI models from Hugging Face (search, download, manage)
# POS_SUBCMDS: search download list remove info files cache
# POS_FLAGS: --branch --gguf --list --output --quant --include --exclude --revision
# POS_DEPS: curl jq
# POS_CONFIG: ai | ai.env | HF_TOKEN=secret:Hugging Face API token (https://huggingface.co/settings/tokens) | HF_DOWNLOAD_DIR=:Model download directory (default ~/.local/share/linux_post_install/ai/models)
@@ -18,29 +19,20 @@ set -euo pipefail
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# ── Dependencies (before --help) ───────────────────────────────
command -v curl &>/dev/null || err "curl not found (install curl)"
command -v jq &>/dev/null || err "jq not found (install jq)"
# ── Config & defaults ──────────────────────────────────────────
CONFIG_FILE="${CONFIG_FILE:-$HOME/.config/linux_post_install/ai.env}"
CONFIG_FILE="${CONFIG_FILE:-$CONFIG_DIR/ai.env}"
HF_TOKEN="${HF_TOKEN:-}"
HF_DOWNLOAD_DIR="${HF_DOWNLOAD_DIR:-$HOME/.local/share/linux_post_install/ai/models}"
load_hf_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
v="${v//$'\r'/}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
load_env_file "$CONFIG_FILE"
}
load_hf_config
@@ -698,7 +690,9 @@ cmd_download() {
rm -rf "$temp_dir"
trap - EXIT
else
# Single file download - use original sequential approach
# Single-file download — the same honesty rules as the parallel path:
# a failure is recorded in failed_files so the model is never marked
# complete (.hf-meta suppressed) and the tool exits rc 1.
while IFS= read -r file_json; do
local fname fsize
fname="$(printf '%s' "$file_json" | jq -r '.rfilename')"
@@ -708,12 +702,8 @@ cmd_download() {
local url="${HF_BASE}/${ns}/${repo}/resolve/${branch}/${fname}"
local target="${target_dir}/${fname}"
if [ "$file_count" -gt 1 ]; then
downloaded=$((downloaded + 1))
printf '[%d/%d] Downloading %s...\n' "$downloaded" "$file_count" "$fname" >&2
fi
if ! hf_download_with_progress "$url" "$target"; then
failed_files+=("$fname")
warn "Failed to download $fname"
continue
fi
@@ -743,8 +733,15 @@ METAEOF
warn "Not writing .hf-meta — ${repo_id} is incomplete (${#failed_files[@]} file(s) failed)"
fi
# Summary
if [ "$file_count" -eq 1 ]; then
# Summary — honest in both single-file and parallel paths: any failure
# yields a success/failure count, never a false "downloaded" claim.
if [ "${#failed_files[@]}" -gt 0 ]; then
# Honest count: attempted = total files, success = total failures
local success_count=$((file_count - ${#failed_files[@]}))
printf '📥 Downloaded: %s (%d of %d files, %d failed: %s)\n' \
"$repo_id" "$success_count" "$file_count" "${#failed_files[@]}" "${failed_files[*]}"
printf '📁 %s/\n' "$target_dir"
elif [ "$file_count" -eq 1 ]; then
local fname
fname="$(printf '%s' "$filtered_files" | jq -r '.[0].rfilename')"
local fsize
@@ -754,22 +751,14 @@ METAEOF
printf '📥 Downloaded: %s/%s (%s)\n' "$repo_id" "$fname" "$human_size"
printf '📁 %s/%s\n' "$target_dir" "$fname"
else
if [ "${#failed_files[@]}" -gt 0 ]; then
# Honest count: attempted = total files, success = total failures
local success_count=$((file_count - ${#failed_files[@]}))
printf '📥 Downloaded: %s (%d of %d files, %d failed: %s)\n' \
"$repo_id" "$success_count" "$file_count" "${#failed_files[@]}" "${failed_files[*]}"
else
local total_human
total_human="$(hf_human_size "$total_size")"
printf '📥 Downloaded: %s (%d files, %s)\n' "$repo_id" "$file_count" "$total_human"
fi
local total_human
total_human="$(hf_human_size "$total_size")"
printf '📥 Downloaded: %s (%d files, %s)\n' "$repo_id" "$file_count" "$total_human"
printf '📁 %s/\n' "$target_dir"
fi
# A partially-failed parallel batch must be detectable by scripts —
# exit non-zero. The sequential single-file path is unchanged: it never
# populates failed_files, so this clause only fires for the parallel path.
# Any failure — single-file or parallel batch must be detectable by
# scripts: exit non-zero.
if [ "${#failed_files[@]}" -gt 0 ]; then
return 1
fi
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai llamacpp — Forward to pos ai --provider llamacpp (backward compat)
# POS_SUBCMDS: ask chat models sessions capture
# POS_SUBCMDS: ask chat models sessions capture providers
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/llamacpp.sh
case "${1:-}" in -h|--help) exec pos ai --provider llamacpp --help ;; esac
exec pos ai --provider llamacpp "$@"
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai openrouter — Forward to pos ai --provider openrouter (backward compat)
# POS_SUBCMDS: ask chat sessions capture
# POS_SUBCMDS: ask chat sessions capture models providers
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/openrouter.sh
case "${1:-}" in -h|--help) exec pos ai --provider openrouter --help ;; esac
exec pos ai --provider openrouter "$@"
+113 -29
View File
@@ -7,35 +7,50 @@ set -euo pipefail
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# ── Dependencies (before --help) ───────────────────────────────
command -v curl &>/dev/null || err "curl not found (install curl)"
command -v jq &>/dev/null || err "jq not found (install jq)"
# ── Config / seams ─────────────────────────────────────────────
CONFIG_FILE="${CONFIG_FILE:-$HOME/.config/linux_post_install/ai.env}"
CONFIG_FILE="${CONFIG_FILE:-$CONFIG_DIR/ai.env}"
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
SERVICE="pos-ai-server.service"
HF_DOWNLOAD_DIR="${HF_DOWNLOAD_DIR:-$HOME/.local/share/linux_post_install/ai/models}"
# ── Config loader (env-var precedence, same pattern as pos-ai-hf) ──
# ── Config loader (canonical env-var precedence, same pattern as pos-ai-hf) ──
# Loaded keys are also recorded in LOADED_ENV_KEYS (see below); the D-F
# requested-from-config tracking uses the exported values, so it is
# unaffected by the loader implementation.
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
v="${v//$'\r'/}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
load_env_file "$CONFIG_FILE"
}
load_config
# ── Config/env-requested flags (D-F) ───────────────────────────
# Flags the user explicitly configured (via ai.env or exported env) are
# validated as "requested" (hard error if unsupported), distinct from the
# always-emitted tool defaults (warn + omit). This is captured here, BEFORE
# the CLI parse overwrites the LLAMACPP_* variables, so it reflects genuine
# user intent rather than the final defaulted values.
CONFIG_REQUESTED_FLAGS=()
requested_from_env_config() {
local var="$1" flag="$2"
[ -n "${!var:-}" ] || return 0
case " ${CONFIG_REQUESTED_FLAGS[*]:-} " in
*" $flag "*) ;; # dedupe
*) CONFIG_REQUESTED_FLAGS+=("$flag") ;;
esac
}
requested_from_env_config LLAMACPP_PORT --port
requested_from_env_config LLAMACPP_HOST --host
requested_from_env_config LLAMACPP_CTX_SIZE --ctx-size
requested_from_env_config LLAMACPP_GPU_LAYERS --n-gpu-layers
requested_from_env_config LLAMACPP_THREADS --threads
# ── Binary detection ───────────────────────────────────────────
find_llamacpp() {
local candidates=("llama-server" "llama.cpp/server" "server" "llama-server-cuda")
@@ -80,12 +95,62 @@ validate_requested_flags() {
*" $flag "*) continue ;; # dedupe alias-mapped flags (e.g. --gpu → --n-gpu-layers)
esac
seen+=("$flag")
if ! printf '%s' "$help_text" | grep -qF -- "$flag"; then
# Word-boundary match: the flag token must appear as a whole word in
# --help, not as a substring of a longer flag (D4 — e.g. --mmap must
# not match a --no-mmap entry).
if ! printf '%s' "$help_text" | grep -qE -- "(^|[[:space:]])${flag}([[:space:]]|=|$)"; then
err "installed llama.cpp ${version} does not expose ${flag} — remove it or upgrade llama.cpp"
fi
done
}
# ── Validate always-emitted default flags (D-F) ────────────────
# validate_default_flags <binary> <version> <requested-flag...> — for every
# flag the tool emits BY DEFAULT (with no explicit user intent), check it is
# supported in --help. Requested flags (CLI or config/env) are excluded — they
# were hard-validated upstream and must be kept. An unsupported DEFAULT is
# omitted from ExecStart with a single warn (never a hard error — the tool
# chose the default, not the user). Readable --help sets globals:
# DEFAULT_PORT_OK DEFAULT_HOST_OK DEFAULT_GPU_OK DEFAULT_CTX_OK DEFAULT_THREADS_OK
# (all 1 = keep; 0 = omit). Unreadable --help text sets all OK = 1 (warn).
validate_default_flags() {
local bin="$1" version="$2"
shift 2
local requested=("$@")
local requested_str=" ${requested[*]:-} "
DEFAULT_PORT_OK=1; DEFAULT_HOST_OK=1; DEFAULT_GPU_OK=1; DEFAULT_CTX_OK=1; DEFAULT_THREADS_OK=1
local help_text
help_text="$("$bin" --help 2>/dev/null)" || {
warn "Cannot obtain llama-server --help output — skipping default flag validation"
return 0
}
local spec flag ok_var
# flag | ok_var — the always-emitted defaults a user may not have requested.
local specs=(
"--port|DEFAULT_PORT_OK"
"--host|DEFAULT_HOST_OK"
"--n-gpu-layers|DEFAULT_GPU_OK"
"--ctx-size|DEFAULT_CTX_OK"
"--threads|DEFAULT_THREADS_OK"
)
for spec in "${specs[@]}"; do
flag="${spec%%|*}"
ok_var="${spec#*|}"
case "$requested_str" in
*" $flag "*) continue ;; # requested → already hard-validated, keep
esac
if printf '%s' "$help_text" | grep -qE -- "(^|[[:space:]])${flag}([[:space:]]|=|$)"; then
eval "$ok_var=1"
else
warn "installed llama.cpp ${version} does not support default flag ${flag} — omitting it from the unit"
eval "$ok_var=0"
fi
done
}
# ── GPU detection ──────────────────────────────────────────────
detect_gpu() {
if command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null 2>&1; then
@@ -126,9 +191,11 @@ human_size() {
# ── Health check ───────────────────────────────────────────────
check_health() {
local port="${LLAMACPP_PORT:-8088}"
# Probe the SAME host/port the unit binds (HOST/PORT, defaults
# 127.0.0.1/8088) — previously probes hardcoded 127.0.0.1 and missed a
# non-localhost LLAMACPP_HOST bind.
local resp
resp="$(curl -sf "http://127.0.0.1:$port/health" 2>/dev/null)" || { echo "not running"; return 1; }
resp="$(curl -sf "http://$HOST:$PORT/health" 2>/dev/null)" || { echo "not running"; return 1; }
local status
status="$(printf '%s' "$resp" | jq -r '.status // "unknown"' 2>/dev/null)"
echo "$status"
@@ -253,7 +320,7 @@ Config (~/.config/linux_post_install/ai.env):
LLAMACPP_GPU_LAYERS GPU layers: -1=auto, 0=CPU only (default -1)
LLAMACPP_THREADS CPU threads (default: nproc)
Requires: llama-server binary (install llama.cpp: https://github.com/ggerganov/llama.cpp)
Requires: llama-server binary install llama.cpp with the app installer: 'apps/ai/llamacpp.sh' (run 'bash apps/install.sh llamacpp', or pass '--apps'/'--full' to install.sh), see 'pos help ai server' (https://github.com/ggerganov/llama.cpp)
EOF
exit 0
}
@@ -394,7 +461,7 @@ systemd_quote() {
cmd_start() {
# Resolve the llama-server binary
local llamacpp_bin
llamacpp_bin="$(find_llamacpp)" || err "llama-server not found — install llama.cpp (https://github.com/ggerganov/llama.cpp)"
llamacpp_bin="$(find_llamacpp)" || err "llama-server not found — install llama.cpp with the app installer: 'apps/ai/llamacpp.sh' (run 'bash apps/install.sh llamacpp', or pass '--apps'/'--full' to install.sh), see 'pos help ai server' (https://github.com/ggerganov/llama.cpp)"
local llamacpp_full
llamacpp_full="$(command -v "$llamacpp_bin")"
@@ -403,10 +470,21 @@ cmd_start() {
local version
version="$(detect_llama_version "$llamacpp_bin")"
# Validate explicitly requested flags against this binary's --help
if [ "${#REQUESTED_FLAGS[@]}" -gt 0 ]; then
validate_requested_flags "$llamacpp_bin" "$version" "${REQUESTED_FLAGS[@]}"
# Validate EVERY flag that will appear in ExecStart (D-F):
# - Requested flags (CLI OR config/env) → hard error if unsupported.
# - Always-emitted defaults → warn + omit if unsupported.
local all_requested=("${REQUESTED_FLAGS[@]}" "${CONFIG_REQUESTED_FLAGS[@]}")
local deduped=() flag
for flag in "${all_requested[@]}"; do
case " ${deduped[*]:-} " in
*" $flag "*) continue ;;
esac
deduped+=("$flag")
done
if [ "${#deduped[@]}" -gt 0 ]; then
validate_requested_flags "$llamacpp_bin" "$version" "${deduped[@]}"
fi
validate_default_flags "$llamacpp_bin" "$version" "${deduped[@]}"
# Resolve model
local explicit_model="${SUBCMD_ARGS[0]:-}"
@@ -441,11 +519,17 @@ cmd_start() {
# unquoted whitespace, so the binary and the model path — the only tokens
# that may contain spaces — are systemd_quote()d; plain flag/number
# tokens stay unquoted.
# Build the command with ONLY the flags that passed validation. Requested
# flags (hard-validated) and supported defaults are always emitted; an
# unsupported DEFAULT is omitted here (validate_default_flags set the
# DEFAULT_*_OK globals) so the unit never carries an unsupported flag.
local exec_cmd
exec_cmd="$(systemd_quote "$llamacpp_full") -m $(systemd_quote "$model") --port $PORT --host $HOST"
exec_cmd+=" --n-gpu-layers $gpu_layers"
exec_cmd+=" --ctx-size $CTX_SIZE"
exec_cmd+=" --threads $THREADS"
exec_cmd="$(systemd_quote "$llamacpp_full") -m $(systemd_quote "$model")"
if [ "$DEFAULT_PORT_OK" -eq 1 ]; then exec_cmd+=" --port $PORT"; fi
if [ "$DEFAULT_HOST_OK" -eq 1 ]; then exec_cmd+=" --host $HOST"; fi
if [ "$DEFAULT_GPU_OK" -eq 1 ]; then exec_cmd+=" --n-gpu-layers $gpu_layers"; fi
if [ "$DEFAULT_CTX_OK" -eq 1 ]; then exec_cmd+=" --ctx-size $CTX_SIZE"; fi
if [ "$DEFAULT_THREADS_OK" -eq 1 ]; then exec_cmd+=" --threads $THREADS"; fi
if [ -n "$GPU_THREADS" ]; then
exec_cmd+=" --gpu-threads $GPU_THREADS"
fi
@@ -560,7 +644,7 @@ cmd_status() {
# llama-server must be present for the version probe below — same
# actionable deps message as `start`
if ! find_llamacpp >/dev/null 2>&1; then
err "llama-server not found — install llama.cpp (https://github.com/ggerganov/llama.cpp)"
err "llama-server not found — install llama.cpp with the app installer: 'apps/ai/llamacpp.sh' (run 'bash apps/install.sh llamacpp', or pass '--apps'/'--full' to install.sh), see 'pos help ai server' (https://github.com/ggerganov/llama.cpp)"
fi
# Service state
@@ -573,7 +657,7 @@ cmd_status() {
# Model (from health endpoint if running)
if [ "$svc_state" = "running" ]; then
local models_resp
models_resp="$(curl -sf "http://127.0.0.1:$PORT/v1/models" 2>/dev/null)" || true
models_resp="$(curl -sf "http://$HOST:$PORT/v1/models" 2>/dev/null)" || true
local model_id
model_id="$(printf '%s' "$models_resp" | jq -r '.data[0].id // "unknown"' 2>/dev/null)" || model_id="unknown"
printf 'model: %s\n' "$model_id"
+19 -20
View File
@@ -9,6 +9,9 @@ MAP_FILE="$CONFIG_DIR/matrix_commands.env"
SERVICE="pos-matrix-listener.service"
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# System prompt for the "ai " bridge: replies are posted straight into the
# room, so ask for concise, emoji-friendly Matrix-style answers.
AI_SYSTEM="You are a friendly assistant chatting in a Matrix room. Keep replies concise, use emojis and light formatting to make them lively, and never claim to send messages yourself."
@@ -31,7 +34,8 @@ Commands:
--run Run the polling loop in the foreground (used by the service)
Config: $CONFIG_FILE (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN,
MATRIX_USER_ID — edit with 'pos config matrix')
MATRIX_USER_ID, MATRIX_ROOM_ID — edit with
'pos config matrix')
Map: $MAP_FILE — '/cmd=bash command' per line (optional
'/cmd::short description=bash command')
@@ -44,10 +48,11 @@ without '@quiet' and the listener forwards the output:
'/status=pos system health'
The listener only reacts to messages from your own Matrix user
(MATRIX_USER_ID). If MATRIX_ROOM_ID is set it only watches that room,
otherwise it watches every room you've joined. Commands run as your user,
so 'sudo' inside them needs a NOPASSWD rule. The interactive editor runs
'bash -n' to syntax-check commands before saving.
(MATRIX_USER_ID), and only in the room set by MATRIX_ROOM_ID — without it
the listener refuses to run any command (fail-closed), so a bot account
that has joined many rooms can't be tricked into executing commands.
Commands run as your user, so 'sudo' inside them needs a NOPASSWD rule.
The interactive editor runs 'bash -n' to syntax-check commands before saving.
Commands are matched with a leading '/' or '!' — '/status' and '!status'
both resolve. There is no equivalent of Telegram's bot "/" menu on Matrix,
@@ -63,18 +68,7 @@ EOF
# ── matrix.env (same pattern as pos-communication-matrix-sender) ──
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
load_env_file "$CONFIG_FILE"
}
api_base() {
@@ -469,7 +463,7 @@ handle_message() {
return
fi
log "ai: $prompt"
if answer="$(timeout 120 pos ai gemini ask --session "$session" --system "$AI_SYSTEM" "$prompt" 2>&1)"; then
if answer="$(timeout 120 pos ai gemini ask --no-command-execution --session "$session" --system "$AI_SYSTEM" "$prompt" 2>&1)"; then
reply_room "$room" "$(strip_markdown "$answer")" "$event_id"
else
[ -n "$answer" ] || answer="timed out after 120s"
@@ -513,6 +507,9 @@ run_daemon() {
[ -n "${MATRIX_ACCESS_TOKEN:-}" ] || err "No access token — run 'pos communication matrix sender login'"
local owner="${MATRIX_USER_ID:-}"
local room_only="${MATRIX_ROOM_ID:-}"
if [ -z "$room_only" ]; then
warn "MATRIX_ROOM_ID unset — refusing to run commands (fail-closed); set it with 'pos config matrix'"
fi
if [ -z "$owner" ]; then
local who
who="$(curl -fsS -m 30 -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \
@@ -524,7 +521,7 @@ run_daemon() {
local since="" filter_enc resp nb
filter_enc="$(printf '%s' "$SYNC_FILTER" | jq -sRr @uri)"
log "listener running (owner ${owner}${room_only:+ — room ${room_only}}) — Ctrl+C to stop"
log "listener running (owner ${owner}, room ${room_only:-none — fail-closed}) — Ctrl+C to stop"
trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM INT
while true; do
local args=("$(api_base)/_matrix/client/v3/sync?timeout=30000&filter=${filter_enc}")
@@ -536,7 +533,9 @@ run_daemon() {
local room ecount i roomid ev sender text eid
for room in $(printf '%s' "$resp" | jq -r '.rooms.join // {} | to_entries[] | .key' 2>/dev/null); do
if [ -n "$room_only" ] && [ "$room" != "$room_only" ]; then
# Fail-closed: with MATRIX_ROOM_ID unset no room is watched, so no
# command can ever run (previously every joined room was watched).
if [ -z "$room_only" ] || [ "$room" != "$room_only" ]; then
continue
fi
ecount="$(printf '%s' "$resp" | jq -r --arg r "$room" ".rooms.join[\"$room\"].timeline.events // [] | length")"
+4 -13
View File
@@ -7,6 +7,9 @@ set -euo pipefail
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
CONFIG_FILE="$CONFIG_DIR/matrix.env"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
usage() {
cat <<EOF
Usage: pos communication matrix sender [command] [args]
@@ -42,19 +45,7 @@ err() { echo "ERROR: $*" >&2; exit 1; }
log() { echo "[+] $*"; }
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
v="${v//$'\r'/}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
load_env_file "$CONFIG_FILE"
}
save_config() {
+5 -14
View File
@@ -6,25 +6,16 @@ set -euo pipefail
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
CONFIG_FILE="$HOME/.config/linux_post_install/scrcpy.env"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
CONFIG_FILE="$CONFIG_DIR/scrcpy.env"
command -v scrcpy &>/dev/null || err "scrcpy not found — install the latest release with the app installer: 'apps/media/scrcpy.sh' (or 'sudo apt install scrcpy' if your distro ships it; it bundles adb), see 'pos help communication scrcpy'"
command -v adb &>/dev/null || err "adb not found — install it: 'sudo apt install adb'"
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
v="${v//$'\r'/}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
load_env_file "$CONFIG_FILE"
}
load_config
+27 -17
View File
@@ -12,6 +12,9 @@ API="https://api.telegram.org"
SERVICE="pos-telegram-listener.service"
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# System prompt for the "<prefix> " AI bridge (default prefix: "ai"): replies
# are posted straight into the chat, so ask for concise, emoji-friendly
# Telegram-style answers. The trigger word is configurable via
@@ -47,7 +50,8 @@ Commands:
is set via 'pos config telegram' (TELEGRAM_AI_PREFIX).
Config: $CONFIG_FILE (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
TELEGRAM_AI_PREFIX — edit with 'pos config telegram')
TELEGRAM_OWNER_ID, TELEGRAM_AI_PREFIX — edit with 'pos
config telegram')
Map: $MAP_FILE — '/cmd=bash command' per line (optional
'/cmd::short description=bash command' shown in the bot menu)
Prefix map: $PREFIX_FILE — '<word>=command' per line: a non-command
@@ -65,7 +69,11 @@ To reply with a command's stdout (e.g. the health dashboard), just map it
without '@quiet' and the listener forwards the output:
'/status=pos system health'
The listener only reacts to the owner chat (TELEGRAM_CHAT_ID). Commands run
The listener only reacts to messages sent to the owner chat
(TELEGRAM_CHAT_ID) BY your account (TELEGRAM_OWNER_ID) — both must match,
so an impersonator or a forwarded message can't trigger commands. If
TELEGRAM_OWNER_ID is unset the daemon starts but refuses to run any chat
command (fail-closed); set it with 'pos config telegram'. Commands run
as your user, so 'sudo' inside them needs a NOPASSWD rule. The interactive
editor runs 'bash -n' to syntax-check commands before saving.
@@ -84,18 +92,7 @@ EOF
# ── telegram.env (same pattern as pos-communication-telegram) ────
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
load_env_file "$CONFIG_FILE"
}
# ── command map (MAP_FILE) ──────────────────────────────────────
@@ -731,7 +728,7 @@ handle_message() {
if [ -n "$reply_text" ]; then
prompt="[Reply context — the message you are replying to]\n${reply_text}\n\n${prompt}"
fi
if answer="$(timeout 120 pos ai gemini ask --session "$session" --system "$AI_SYSTEM" "$prompt" 2>&1)"; then
if answer="$(timeout 120 pos ai gemini ask --no-command-execution --session "$session" --system "$AI_SYSTEM" "$prompt" 2>&1)"; then
reply "$(strip_markdown "$answer")" "$msg_id"
else
[ -n "$answer" ] || answer="timed out after 120s"
@@ -758,10 +755,15 @@ run_daemon() {
load_config
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] || err "No bot token — run 'pos config telegram'"
[ -n "${TELEGRAM_CHAT_ID:-}" ] || err "No chat id — run 'pos config telegram'"
if [ -n "${TELEGRAM_OWNER_ID:-}" ]; then
log "owner id ${TELEGRAM_OWNER_ID} — commands authorized"
else
warn "TELEGRAM_OWNER_ID unset — chat commands WILL BE IGNORED (fail-closed); set it with 'pos config telegram'"
fi
sync_bot_commands || true
local offset=0
log "listener running (owner chat ${TELEGRAM_CHAT_ID}) — Ctrl+C to stop"
log "listener running (chat ${TELEGRAM_CHAT_ID}, owner ${TELEGRAM_OWNER_ID:-unset}) — Ctrl+C to stop"
trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM INT
while true; do
local resp n i
@@ -784,7 +786,15 @@ run_daemon() {
reply_text="$(printf '%s' "$resp" | jq -r ".result[$i].message.reply_to_message.text // .result[$i].message.reply_to_message.caption // empty")"
offset=$((u + 1))
[ -n "$text" ] || continue
if [ -n "$chat" ] && [ "$chat" != "$TELEGRAM_CHAT_ID" ] && [ "$from_id" != "$TELEGRAM_CHAT_ID" ]; then
if [ -z "${TELEGRAM_OWNER_ID:-}" ]; then
warn "TELEGRAM_OWNER_ID unset — ignoring command (set it with 'pos config telegram')"
continue
fi
# Fail-closed owner check: the message must be in the owner chat
# AND sent by the owner account. Previously a chat-id OR sender-id
# match was enough — anyone who knew the chat id could run commands.
if [ "$chat" != "$TELEGRAM_CHAT_ID" ] || [ "$from_id" != "$TELEGRAM_OWNER_ID" ]; then
warn "ignoring message in chat ${chat:-?} from ${from_id:-?} (not the owner chat/account)"
continue
fi
handle_message "$text" "$msg_id" "$reply_text"
+7 -16
View File
@@ -3,12 +3,15 @@ set -euo pipefail
# POS: communication telegram-sender — Send Telegram messages/files/links/stickers via Bot API (send, test)
# POS_FLAGS: --type --caption --parse-mode --no-preview --token --chat-id --markdown
# POS_SUBCMDS: send test
# POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token from @BotFather | TELEGRAM_CHAT_ID=digits:Numeric chat id from @userinfobot | TELEGRAM_AI_PREFIX=:AI-bridge trigger word in the telegram listener (default ai)::ai
# POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token from @BotFather | TELEGRAM_CHAT_ID=digits:Numeric chat id from @userinfobot | TELEGRAM_OWNER_ID=digits:Numeric Telegram user id (your account) allowed to run chat commands | TELEGRAM_AI_PREFIX=:AI-bridge trigger word in the telegram listener (default ai)::ai
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
CONFIG_FILE="$CONFIG_DIR/telegram.env"
API="https://api.telegram.org"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
usage() {
cat <<EOF
Usage: pos communication telegram sender [command] [args]
@@ -39,8 +42,8 @@ Options:
--token <t> Override token for one send
--chat-id <id> Override chat id for one send
Config: $CONFIG_FILE (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID)
edit it with 'pos config telegram'
Config: $CONFIG_FILE (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
TELEGRAM_OWNER_ID — edit it with 'pos config telegram')
Precedence: CLI flags > environment > config file.
@@ -59,19 +62,7 @@ EOF
err() { echo "ERROR: $*" >&2; exit 1; }
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
v="${v//$'\r'/}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
load_env_file "$CONFIG_FILE"
}
send_request() {
+4 -12
View File
@@ -6,20 +6,12 @@ set -euo pipefail
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# Load grab.env config (env-seam: GRAB_DEFAULT)
load_grab_config() {
local f="$HOME/.config/linux_post_install/grab.env" k v
[ -f "$f" ] || return 0
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$f" || true)
load_env_file "$CONFIG_DIR/grab.env"
}
load_grab_config
+6 -4
View File
@@ -130,7 +130,9 @@ port_meta() {
# --- fallback engine (no nmap): bash /dev/tcp + nc ---------------------------
check_tcp() {
local ip="$1" port="$2" tmo="$3" rc=""
timeout "$tmo" bash -c "exec 3<>/dev/tcp/$ip/$port" 2>/dev/null
# host/port are positional args ($1/$2), never interpolated into the
# command source — a hostile host string stays a literal argument.
timeout "$tmo" bash -c 'exec 3<>/dev/tcp/$1/$2' _ "$ip" "$port" 2>/dev/null
rc=$?
if [ "$rc" -eq 0 ]; then printf 'open'
elif [ "$rc" -eq 124 ]; then printf 'timeout'
@@ -154,7 +156,7 @@ check_udp() {
elif printf '%s' "$out" | grep -qiE 'refus|unreachable'; then printf 'closed'
else printf 'no-reply'; fi
else
timeout "$tmo" bash -c "exec 3<>/dev/udp/$ip/$port; printf 'x' >&3" 2>/dev/null
timeout "$tmo" bash -c 'exec 3<>/dev/udp/$1/$2; printf "x" >&3' _ "$ip" "$port" 2>/dev/null
printf 'no-reply'
fi
}
@@ -163,9 +165,9 @@ probe() {
local ip="$1" port="$2" tmo="$3"
case "$port" in
80|8000|8080|8081|8888|3000|5000|9090|3128|10000)
timeout "$tmo" bash -c "exec 3<>/dev/tcp/$ip/$port; printf 'HEAD / HTTP/1.0\r\n\r\n' >&3; head -c 300 <&3" 2>/dev/null ;;
timeout "$tmo" bash -c 'exec 3<>/dev/tcp/$1/$2; printf "HEAD / HTTP/1.0\r\n\r\n" >&3; head -c 300 <&3' _ "$ip" "$port" 2>/dev/null ;;
*)
timeout "$tmo" bash -c "exec 3<>/dev/tcp/$ip/$port; head -c 200 <&3" 2>/dev/null ;;
timeout "$tmo" bash -c 'exec 3<>/dev/tcp/$1/$2; head -c 200 <&3' _ "$ip" "$port" 2>/dev/null ;;
esac
}
+8 -6
View File
@@ -11,6 +11,9 @@ set -euo pipefail
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
# Shared config loader (canonical KEY=VALUE parser, env-wins precedence)
source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
command -v aria2c &>/dev/null || err "aria2c not found (install aria2)"
command -v jq &>/dev/null || err "jq not found (install jq)"
command -v curl &>/dev/null || err "curl not found (install curl)"
@@ -25,14 +28,13 @@ SERVICE="pos-aria2.service"
HEALER_SERVICE="pos-aria2-retry.service"
HEALER_TIMER="pos-aria2-retry.timer"
RETRY_STATE="$CONFIG_DIR/download.retry"
NET_PROBE="${NET_PROBE:-timeout 3 bash -c '</dev/tcp/8.8.8.8/53>'}"
NET_PROBE="${NET_PROBE:-timeout 3 bash -c 'exec 3<>/dev/tcp/\$1/\$2' _ 8.8.8.8 53}"
RETRY_INTERVAL="${RETRY_INTERVAL:-30}"
RETRY_VERIFY_SLEEP="${RETRY_VERIFY_SLEEP:-3}"
load_secret() {
if [ -z "${RPC_SECRET:-}" ] && [ -f "$CONFIG_FILE" ]; then
RPC_SECRET=$(grep -E '^RPC_SECRET=' "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2-)
fi
# Canonical loader: env-wins, so an exported RPC_SECRET always beats the file.
load_env_file "$CONFIG_FILE"
RPC_SECRET="${RPC_SECRET:-}"
}
load_secret
@@ -150,8 +152,8 @@ rpc() { # rpc <method> [json-args...]
daemon_active() { systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; }
cmd_start() {
if [ -z "$RPC_SECRET" ] && [ -f "$CONFIG_FILE" ]; then
RPC_SECRET=$(grep -E '^RPC_SECRET=' "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2-)
if [ -z "$RPC_SECRET" ]; then
load_secret
fi
if [ -z "$RPC_SECRET" ]; then
RPC_SECRET=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')
+3 -1
View File
@@ -89,7 +89,9 @@ split_share() {
# TCP connect probe against the server before touching passwords or mounts.
probe_server() {
local host="$1"
if timeout 3 bash -c "exec 3<>/dev/tcp/${host}/${SMB_PORT}" 2>/dev/null; then
# host/port are positional args, never interpolated into the command
# source — a hostile share/address string stays a literal argument.
if timeout 3 bash -c 'exec 3<>/dev/tcp/$1/$2' _ "$host" "$SMB_PORT" 2>/dev/null; then
return 0
fi
err "Server ${host} not reachable on port ${SMB_PORT} (SMB) — nothing was mounted.
+10 -2
View File
@@ -192,14 +192,22 @@ run_backup() {
unset CONFIRM
log "Encrypting backup..."
gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE"
# The passphrase is fed on fd 3 (here-string), never via argv, so it
# cannot leak through 'ps' / /proc/<pid>/cmdline while gpg runs.
if ! gpg --batch --yes --passphrase-fd 3 --symmetric --cipher-algo AES256 "$ARCHIVE" 3<<<"$PASS"; then
rm -f "$ARCHIVE"
err "encryption failed — plaintext archive removed, nothing left behind"
fi
rm -f "$ARCHIVE"
ARCHIVE="${ARCHIVE}.gpg"
chmod 600 "$ARCHIVE"
log "Verifying encrypted backup..."
gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf - > /dev/null
if ! gpg --batch --quiet --passphrase-fd 3 --decrypt "$ARCHIVE" 3<<<"$PASS" | tar -tzf - > /dev/null; then
rm -f "$ARCHIVE"
err "decryption verification failed — corrupt artifact removed"
fi
unset PASS
else
+118 -36
View File
@@ -25,6 +25,14 @@ Examples:
pos system uninstall # interactive, tier 1 only
pos system uninstall --yes # non-interactive, tier 1 only
pos system uninstall --yes --config --data # nuclear option
Intentionally NOT removed (user-managed):
- apt packages (system packages installed by preinstall.sh)
- /usr/local/bin/yt-dlp (manually installed)
- ~/.config/rclone/ (rclone manages its own config)
- ~/.ssh/authorized_keys additions (user SSH access)
- pos-owned config files (removed by --config tier)
- pos-owned data files (removed by --data tier)
EOF
exit 0
}
@@ -44,6 +52,27 @@ while [ $# -gt 0 ]; do
esac
done
# ── pos-owned installed files (single source of truth, mirrors install.sh) ──
# lib/*.sh list shipped by install.sh phase 2 to /usr/local/bin.
POS_LIBS=(common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh \
user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh registry.sh)
# Exact lines postinstall.sh appends to ~/.bashrc.
PATH_LINE='export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$HOME/.local/bin:$PATH"'
COMPLETION_LINE='source /usr/local/share/bash-completion/completions/pos.bash 2>/dev/null || true'
# Pos-ai-hook source lines are not written by the installer (user/legacy-added),
# so they are matched by an anchored source-pattern, never a bare substring.
HOOK_PATTERN='^[[:space:]]*source[[:space:]].*pos-ai-hook\.sh'
# Entertainment plugins installed by install.sh — discovered by POS_PLUGIN marker
# so future plugins are removed without touching this file.
installed_plugins() {
local ep
for ep in /usr/local/bin/*.sh; do
[ -f "$ep" ] || continue
grep -q '^# POS_PLUGIN:' "$ep" 2>/dev/null && echo "$ep"
done | sort
}
# ── Scan functions ──────────────────────────────────────────────
scan_tier1() {
local found=()
@@ -58,20 +87,18 @@ scan_tier1() {
found+=("$f")
done < <(compgen -G /usr/local/bin/pos-* 2>/dev/null | sort || true)
# Lib files shipped by install.sh
for f in common.sh menu-lib.sh share-lib.sh; do
# Lib files shipped by install.sh (single source: POS_LIBS)
for f in "${POS_LIBS[@]}"; do
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
done
# AI providers subdirectory
[ -d /usr/local/bin/ai-providers ] && found+=("/usr/local/bin/ai-providers/")
# Entertainment plugins installed by install.sh
# Entertainment plugins installed by install.sh (discovered by POS_PLUGIN marker)
while IFS= read -r f; do
found+=("$f")
done < <(for ep in weather.sh gold.sh joke.sh; do
[ -f "/usr/local/bin/$ep" ] && echo "/usr/local/bin/$ep"
done | sort)
[ -n "$f" ] && found+=("$f")
done < <(installed_plugins)
# Legacy forwarders
for f in wr-* mp3 mp4 vbox ssh-load-all; do
@@ -107,11 +134,11 @@ scan_tier1() {
# Completion file
[ -f /usr/local/share/bash-completion/completions/pos.bash ] && found+=("/usr/local/share/bash-completion/completions/pos.bash")
# ~/.bash_completion entries
# ~/.bash_completion entries (anchored source lines only, never /pos/ substring)
if [ -f "$HOME/.bash_completion" ]; then
while IFS= read -r line; do
found+=("~/.bash_completion: $(echo "$line" | sed 's/^[[:space:]]*//' | cut -c1-70)")
done < <(grep -n 'pos' "$HOME/.bash_completion" 2>/dev/null || true)
done < <(grep -nE '^[[:space:]]*source[[:space:]].*pos\.bash' "$HOME/.bash_completion" 2>/dev/null || true)
fi
# ── Systemd services ──
@@ -134,11 +161,24 @@ scan_tier1() {
[ "$already" -eq 0 ] && found+=("service: $svc_name")
done < <(systemctl list-unit-files --type=service 2>/dev/null | grep -i 'linux_post_install\|pos-' || true)
# ── Shell integration (~/.bashrc) ──
# ── ScaleTail templates + feature-flag store (install.sh phase 2/4) ──
[ -d /usr/local/share/linux_post_install/scale-tail ] && found+=("/usr/local/share/linux_post_install/scale-tail/")
[ -d /usr/local/share/linux_post_install/flags ] && found+=("/usr/local/share/linux_post_install/flags/")
# ── Runtime-created user units (pos-* in ~/.config/systemd/user) ──
local user_unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
if [ -d "$user_unit_dir" ]; then
local ufile
while IFS= read -r ufile; do
[ -n "$ufile" ] && found+=("user-unit: $(basename "$ufile")")
done < <(find "$user_unit_dir" -maxdepth 1 -name 'pos-*' -type f 2>/dev/null || true)
fi
# ── Shell integration (~/.bashrc) — exact installer lines + anchored hook ──
if [ -f "$HOME/.bashrc" ]; then
while IFS= read -r line; do
found+=("~/.bashrc: $(echo "$line" | sed 's/^[[:space:]]*//' | cut -c1-70)")
done < <(grep -n 'source.*pos-ai-hook\|linux_post_install.*PATH\|source.*pos\.bash\|pos completion' "$HOME/.bashrc" 2>/dev/null || true)
done < <({ grep -nF "$PATH_LINE" "$HOME/.bashrc"; grep -nF "$COMPLETION_LINE" "$HOME/.bashrc"; grep -nE "$HOOK_PATTERN" "$HOME/.bashrc"; } 2>/dev/null | sort -u || true)
fi
printf '%s\n' "${found[@]}"
@@ -234,9 +274,9 @@ remove_tier1() {
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done < <(compgen -G /usr/local/bin/pos-* 2>/dev/null | sort || true)
# Lib files
for f in /usr/local/bin/common.sh /usr/local/bin/menu-lib.sh /usr/local/bin/share-lib.sh; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
# Lib files (single source: POS_LIBS)
for f in "${POS_LIBS[@]}"; do
[ -f "/usr/local/bin/$f" ] && { rm -f "/usr/local/bin/$f" && count=$((count+1)); }
done
# AI providers directory
@@ -244,10 +284,10 @@ remove_tier1() {
rm -rf /usr/local/bin/ai-providers && count=$((count+1))
fi
# Entertainment plugins
for f in /usr/local/bin/weather.sh /usr/local/bin/gold.sh /usr/local/bin/joke.sh; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done
# Entertainment plugins (discovered by POS_PLUGIN marker)
while IFS= read -r f; do
[ -n "$f" ] && [ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done < <(installed_plugins)
# Legacy forwarders
for pat in 'wr-*' mp3 mp4 vbox ssh-load-all; do
@@ -313,28 +353,70 @@ remove_tier1() {
# Reload daemon after service changes
systemctl daemon-reload 2>/dev/null || true
# ── Shell integration (~/.bashrc) ──
if [ -f "$HOME/.bashrc" ]; then
local before
before=$(wc -l < "$HOME/.bashrc")
sed -i '/source.*pos-ai-hook/d' "$HOME/.bashrc"
sed -i '/linux_post_install.*PATH/d' "$HOME/.bashrc"
sed -i '/source.*pos\.bash/d' "$HOME/.bashrc"
local after
after=$(wc -l < "$HOME/.bashrc")
local removed=$((before - after))
count=$((count + removed))
# ── Runtime-created USER systemd units (pos-* in ~/.config/systemd/user) ──
# Wrapped `|| true`: no user session (e.g. running as a scheduled task) is fine.
local user_unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
if [ -d "$user_unit_dir" ]; then
local ufile uniname
while IFS= read -r ufile; do
[ -n "$ufile" ] || continue
uniname="$(basename "$ufile")"
systemctl --user disable --now "$uniname" 2>/dev/null || true
rm -f "$ufile" && count=$((count+1))
done < <(find "$user_unit_dir" -maxdepth 1 -name 'pos-*' -type f 2>/dev/null || true)
systemctl --user daemon-reload 2>/dev/null || true
fi
# ── Shell completion (~/.bash_completion) ──
# ── ScaleTail templates (submodule clone — guard existence) ──
if [ -d /usr/local/share/linux_post_install/scale-tail ]; then
rm -rf /usr/local/share/linux_post_install/scale-tail && count=$((count+1))
fi
# ── Feature-flag store ──
if [ -d /usr/local/share/linux_post_install/flags ]; then
rm -rf /usr/local/share/linux_post_install/flags && count=$((count+1))
fi
# Clean up parent dir if empty
rmdir /usr/local/share/linux_post_install 2>/dev/null || true
# ── Shell integration (~/.bashrc) — exact installer lines + anchored hook ──
# Only lines postinstall.sh itself added are removed by exact literal match;
# pos-ai-hook lines are matched anchored (never a bare substring).
if [ -f "$HOME/.bashrc" ]; then
local before after removed tmp
before=$(wc -l < "$HOME/.bashrc")
tmp="$(mktemp)"
awk -v p="$PATH_LINE" -v c="$COMPLETION_LINE" -v h="$HOOK_PATTERN" '
$0 == p || $0 == c || $0 ~ h { next }
{ print }
' "$HOME/.bashrc" > "$tmp"
after=$(wc -l < "$tmp")
removed=$((before - after))
if [ "$removed" -gt 0 ]; then
chmod --reference="$HOME/.bashrc" "$tmp"
mv "$tmp" "$HOME/.bashrc"
count=$((count + removed))
else
rm -f "$tmp"
fi
fi
# ── Shell completion (~/.bash_completion) — anchored source lines only ──
if [ -f "$HOME/.bash_completion" ]; then
local before
local before after removed tmp
before=$(wc -l < "$HOME/.bash_completion")
sed -i '/pos/d' "$HOME/.bash_completion"
local after
after=$(wc -l < "$HOME/.bash_completion")
local removed=$((before - after))
count=$((count + removed))
tmp="$(mktemp)"
awk '/^[[:space:]]*source[[:space:]].*pos\.bash/ { next } { print }' "$HOME/.bash_completion" > "$tmp"
after=$(wc -l < "$tmp")
removed=$((before - after))
if [ "$removed" -gt 0 ]; then
chmod --reference="$HOME/.bash_completion" "$tmp"
mv "$tmp" "$HOME/.bash_completion"
count=$((count + removed))
else
rm -f "$tmp"
fi
fi
ok "Removed $count items (tier 1)"