User report after the llamacpp app install: 'installed llama.cpp unknown', valid flags rejected (randomly per run), 'Model not found' for the HF downloader's own layout, and a systemd user-bus failure over SSH. Detective (real b10822 binary, FACT) found four independent causes: - version: llama-server --version prints to STDERR; detect_llama_version's 2>/dev/null swallowed it -> always 'unknown'. Now captures 2>&1 + accepts semver/build tokens (incl. build 1.2.3 edge) - validation: printf|grep -q under pipefail -> SIGPIPE rc=141 race randomly rejected flags present in the 59 KB --help. Now pipe-less grep (no race); 20x determinism regression test - model resolution: resolve_model accepted files only, but the HF downloader creates <models>/<repo>/file.gguf dirs. Now expands a dir with exactly one *.gguf (never silently picks; multi-gguf lists + errs) - port: llama.cpp default 8080 vs tool/adapter 8088; validation reliability means --port is now always pinned in the unit - user bus: headless/SSH sessions lack XDG_RUNTIME_DIR -> ensure_user_bus in lib/common.sh pre-flights all three systemctl --user tools with remediation text; pos ai server --no-unit direct-run escape hatch (pidfile) for boxes with no bus - find_llamacpp narrowed to llama-server/llama-server-cuda (bare 'server' fallback hazard); installer post-install sanity (version+help execute, symlink targets resolve) Architect decisions DQ1-DQ6 recorded. Tester: 4 new regression files (version-from-stderr, 25x flag-validation determinism, model dir expansion, bus pre-flight + E2E) + 3 fixture updates; suite 16 files / 269 checks. Verified: make gen idempotent; make check OK; make lint 0 FAIL, 0 WARN; make test 269/269 (~49s); bash -n clean; git diff --check clean.
51 KiB
Stabilization Pass — Architectural Design Decisions
Date: 2026-09-06
Architect: Evidence-driven design pass over three Explorer audit reports
Constraint: Preserve existing Bash architecture; no framework rewrites; smallest clean fix per issue; no commits; all gates (make gen, make check, make lint) must remain green at 0 FAIL, 0 WARN.
TL;DR
- D-A (Telegram/Matrix auth): Introduce
TELEGRAM_OWNER_IDas an AND-gate withTELEGRAM_CHAT_ID; Matrix requiresMATRIX_ROOM_IDbefore any command runs. Both platforms converge on "sender AND chat authorized" contract; unset owner/room → daemon runs but ignores all commands (soft-fail, ratified at review 2026-09-06). - D-B (AI eval posture): Flip tty confirmation default to
[y/N](deny); keep--trustinteractive-only; add--no-execflag topos-aiand enforce it in chat bridges. Document the bridge invariant. - D-C (Test framework): Create
tests/with a minimal runner,make testtarget, stub-PATH + PTY patterns; first suite covers 14 surfaces. - D-D (Config loader): Canonical
load_env_fileinlib/config-ui.sh; migrate 9 hand-rolled loaders; unify CRLF + XDG; collapse entertainment-lib read/write. - D-E (Install/uninstall manifest): Extend
pos-system-uninstallTier 1 to cover 9 orphaned libs, ScaleTail dir, feature-flag store, and USER systemd units; de-hardcode plugin/binary lists. - D-F (pos-ai-server validation): Validate ALL flags entering ExecStart (CLI + config + defaults); unsupported DEFAULT → warn + omit; unsupported REQUESTED → hard error.
D-A: Chat Authorization Model (Telegram + Matrix)
Decision
Adopt a unified "sender AND chat authorized" authorization contract for both Telegram and Matrix listeners. Concretely:
Telegram:
-
Add
TELEGRAM_OWNER_IDto the# POS_CONFIG:registry inbin/pos-communication-telegram-sender:6and toconfig/telegram.envtemplate. -
Replace the OR-gate at
bin/pos-communication-telegram-listener:787with:chat == TELEGRAM_CHAT_ID(chat must be the configured chat) — fail-continue (skip message silently).from_idmust be one of the comma-separatedTELEGRAM_OWNER_IDvalues — fail-continue (skip message silently).
-
If
TELEGRAM_OWNER_IDis unset, the daemon starts in fail-closed degraded mode:run_daemonlogswarn "TELEGRAM_OWNER_ID unset — chat commands WILL BE IGNORED (fail-closed); set it with 'pos config telegram'"at startup (same location as the token/chat-id guards, line ~761) and a per-messagewarn "TELEGRAM_OWNER_ID unset — ignoring command (set it with 'pos config telegram')"; every incoming command is skipped — no execution, no hint reply.AMENDED at review (2026-09-06): soft-fail ratified as shipped. The original wording required the daemon to
err+exit on unsetTELEGRAM_OWNER_ID. The security-track Builder implemented soft-fail (daemon runs, warns, ignores commands);DOC/POS.md,DOC/howto/communication.md,config/telegram.envandtests/t-telegram-auth.shall consistently describe soft-fail. This is a deliberate deviation ratified at review time (Reviewer finding F2, downgraded). The security property is identical in both models: fail-closed — an unauthenticated/unauthorized command never executes. The difference is purely operational: strict = fail-stop (loud misconfiguration, systemd restart loop); soft-fail = degraded-liveness (silent-to-senders misconfiguration, one clean warning in the local log). Soft-fail was chosen because the entire shipped surface already encoded it and it avoids a systemdRestart=alwayscrash-loop for a non-fatal config gap. -
Reply on unauthorized messages: none (no leak that commands exist). The message is silently dropped.
-
Do not include a "trusted group" backward-compat mode in this pass. The OR-gate was never a documented feature; it was an implementation gap. Backward compatibility for group operation can be added later via an explicit
TELEGRAM_GROUP_MODE=trueopt-in — that is a separate, larger design decision (requires per-user allowlist, rate limiting, etc.) and explicitly out of scope for this stabilization.
Matrix:
-
Require
MATRIX_ROOM_IDfor command execution. If unset, the daemon starts in fail-closed degraded mode: guard inrun_daemonatbin/pos-communication-matrix-listener:510-512logswarn "MATRIX_ROOM_ID unset — refusing to run commands (fail-closed); set it with 'pos config matrix'"; the daemon long-polls but runs no commands.AMENDED at review (2026-09-06): soft-fail ratified as shipped. The original wording required a hard
[ -n "${MATRIX_ROOM_ID:-}" ] || err "MATRIX_ROOM_ID is required — set it in pos config matrix"guard. The security-track Builder implemented soft-fail;DOC/POS.md,DOC/howto/communication.md,config/matrix.envandtests/t-matrix-auth.shall consistently describe soft-fail. Deliberate deviation ratified at review time (Reviewer finding F2, downgraded). Security property identical in both models: fail-closed — no command runs outside the configured room. Same operational trade-off as the Telegram case above (silent-to-senders vs. startup crash-loop). -
Keep the sender=owner gate (
sender == ownerat line 550) unchanged. -
This makes the Matrix behavior: sender authorized (== owner) AND chat authorized (== configured room) — identical to the Telegram contract.
Consistency rule: Both platforms enforce the same conceptual model: a message must come from an authorized sender in an authorized chat. No fallback to "any chat" or "any sender."
Files affected
| File | Change |
|---|---|
bin/pos-communication-telegram-sender:6 |
Add TELEGRAM_OWNER_ID to # POS_CONFIG: registry |
bin/pos-communication-telegram-listener:787-791 |
Replace OR-gate with AND-gate (chat + sender) + soft-fail TELEGRAM_OWNER_ID unset warning in run_daemon and per-message skip |
bin/pos-communication-matrix-listener:510-512 |
Soft-fail MATRIX_ROOM_ID unset warning in run_daemon (commands refused) |
config/telegram.env |
Add TELEGRAM_OWNER_ID= template line |
DOC/HOWTO.md (communication section) |
Document new key, explain AND-gate |
DOC/POS.md (telegram listener section) |
Document authorization model |
Acceptance criteria
- With
TELEGRAM_OWNER_IDunset,pos communication telegram listener --runstarts the daemon, logs a startup warning namingTELEGRAM_OWNER_ID, and ignores every incoming command (no execution, no hint reply). - With
TELEGRAM_OWNER_ID=123andTELEGRAM_CHAT_ID=456, a message fromfrom_id=123inchat=456is dispatched. - A message from
from_id=999inchat=456is silently dropped (no reply, no log of "command not found"). - A message from
from_id=123inchat=789(wrong chat) is silently dropped. - With
MATRIX_ROOM_IDunset,pos communication matrix listener --runstarts the daemon, logs a warning namingMATRIX_ROOM_ID, and runs no commands (fail-closed). - With
MATRIX_ROOM_IDset, messages from the owner in the configured room are dispatched; messages from other senders or other rooms are dropped. make gen && make check && make lintall pass at 0 FAIL, 0 WARN after the change.TELEGRAM_OWNER_IDappears inpos config telegramoutput (masked if flagged asdigitstype, same asTELEGRAM_CHAT_ID).
Risks / open questions
- Existing users who have not set
TELEGRAM_OWNER_IDwill find the daemon stays up but ignores every command after upgrade. Mitigation: the startup warning is actionable ("set it with 'pos config telegram'"). This is intentional — the previous behavior was a security vulnerability (V1), not a feature. Documented residual caveat (soft-fail): misconfiguration is silent to senders — no reply, no error, no hint that commands exist; the only signal is the startup/per-message warning in the local log. Strict mode would have been loud at startup but would crash-loop a systemd-managed daemon for a non-fatal config gap; soft-fail preserves liveness and diagnosability. (Amended 2026-09-06.) - The
TELEGRAM_OWNER_IDsupports comma-separated values (multi-user). Thedigitsflag validation incfg_validatealready allows negative IDs (group/supergroup IDs); forTELEGRAM_OWNER_IDwe should use adigitsflag that also allows comma-separated positive-only values. Decision: use a plaindigitsflag (no comma support) for the initial pass; eachTELEGRAM_OWNER_IDentry is a single numeric user ID. If multi-user is needed, a future pass adds it. This keeps the validation simple and the AND-gate implementation a direct string comparison. - The
TELEGRAM_CHAT_IDcomparison already allows negative values (group IDs). TheTELEGRAM_OWNER_IDshould always be a positive user ID. Thecfg_validatedigitsflag allows leading-; forTELEGRAM_OWNER_IDuse a newpositive-digitsflag or simply document that only positive values are valid for this key.
D-B: Remote AI Command Execution Posture (eval)
Decision
The pos ai command-execution path (_prompt_run_command) evaluates AI-generated shell code. The security posture change addresses three concerns: the default confirmation, the --trust flag, and the bridge invariant.
1. Flip the tty confirmation default to DENY:
Change bin/pos-ai:391 from [Y/n] to [y/N]. The rationale: the AI model's output is untrusted external authority. When a user asks "run X", they mean the task — the specific command the model proposes is the model's interpretation, and a prompt-injection or model error can produce a harmful command. Defaulting to deny means the user must explicitly opt-in (y or Enter is now decline). The [y/N] pattern is the standard bash convention for non-destructive defaults. The user's request for help does not imply authorization to execute arbitrary code.
Current line 391:
printf 'Run this command? [Y/n] ' >&2
Change to:
printf 'Run this command? [y/N] ' >&2
And invert the case logic at line 394: y|Y) executes; * (including Enter) declines and adds to history.
2. --trust flag: keep as-is but document scope:
The --trust flag (TRUST_MODE=1, bin/pos-ai:644) auto-executes without confirmation on a tty only (line 382 [ -w /dev/tty ] || return 0 — non-tty never executes). This is correct: --trust is an explicit operator action on an interactive terminal. No change needed to the flag itself, but:
- Add a
--trustwarning tousage()if not already present: note that this bypasses confirmation and should only be used in trusted local sessions. - Document that
--trusthas no effect when invoked from a chat bridge (non-tty → early return at line 382).
Do NOT rename --trust to --trust-no-confirm; the existing name is clear enough and renaming would break alias wrappers (pos-ai-alias:433-436).
3. Chat bridge invariant: bridges NEVER execute code blocks.
Both Telegram (bin/pos-communication-telegram-listener:734) and Matrix (bin/pos-communication-matrix-listener:472) invoke pos ai gemini ask ... which runs non-interactively. Inside _prompt_run_command (bin/pos-ai:382), [ -w /dev/tty ] || return 0 means code blocks are never executed when the tool runs without a tty — they are printed but not run. This is the correct behavior.
To prevent future regressions if a refactor changes the tty check or adds an auto-confirm path:
- Add
--no-command-executionas a recognized flag inpos-ai(bin/pos-ai:649parse block,# POS_FLAGS:header). - When set,
_prompt_run_commandreturns 0 immediately without printing or executing (same as current non-tty behavior, but explicit). - Both chat bridges pass
--no-command-executionwhen invokingpos ai:bin/pos-communication-telegram-listener:734: add--no-command-executionto the command.bin/pos-communication-matrix-listener:472: same.
- This makes the invariant structurally enforced: even if a future refactor removes the tty check, the bridge-parsed flag still prevents execution.
Files affected
| File | Change |
|---|---|
bin/pos-ai:391 |
Change prompt from [Y/n] to [y/N] |
bin/pos-ai:394-405 |
Invert case logic: `y |
bin/pos-ai:649-677 |
Add --no-command-execution to flag parser |
bin/pos-ai:380-382 |
Check $NO_EXEC flag before the tty check |
bin/pos-ai:5 (# POS_FLAGS:) |
Add --no-command-execution |
bin/pos-communication-telegram-listener:734 |
Add --no-command-execution to AI bridge call |
bin/pos-communication-matrix-listener:472 |
Add --no-command-execution to AI bridge call |
DOC/POS.md (ai section) |
Document new flag and confirmation change |
DOC/HOWTO.md (ai section) |
Document the security posture |
Acceptance criteria
- On a tty,
pos aiwith a command block in the response shows[y/N]and declines on Enter. - On a tty, typing
yorYat the prompt executes the command. pos ai --truststill auto-executes on a tty (no prompt).pos ai --no-command-executionskips execution entirely (no prompt, no output of the command block).pos communication telegram listener→ AI bridge invocation includes--no-command-executionin the actual command string (verifiable by reading the source).- Same for Matrix listener.
make gen && make check && make lintall pass at 0 FAIL, 0 WARN.- The
# POS_FLAGS:header includes--no-command-executionandcompletions/pos.bashupdates accordingly aftermake gen.
Risks / open questions
- Changing the default from ALLOW to DENY is a behavioral breaking change for users who are accustomed to pressing Enter to run. This is intentional and justified by the security audit (V2): the model's output is untrusted. Users who want the old behavior can type
y. - The
--no-command-executionflag name is long. Alternatives:--no-exec,--safe-mode. Decision:--no-command-executionis preferred because it is self-documenting and unambiguous. The flag is consumed programmatically (by bridges), not by humans typing interactively. - Non-tty paths (
[ -w /dev/tty ] || return 0) already prevent execution.--no-command-executionadds defense-in-depth for the tty path in case the tty check is ever removed.
D-C: Test Framework Shape
Decision
Create a committed tests/ directory with a minimal runner and a make test target. The framework follows the repo's established stub-PATH + PTY patterns from DOC/DEV.md:196-214.
Layout:
tests/
run-tests.sh # runner (check helper, pass/fail counting, exit code)
ai-server-flags.sh # unit: flag validation logic
ai-hf-download.sh # unit: single-file failure path
telegram-auth.sh # unit: auth gate logic (mocked)
matrix-auth.sh # unit: auth gate logic (mocked)
config-loader.sh # unit: load_env_file precedence
systemd-unit.sh # unit: unit generation + systemd-analyze (skip if unavailable)
uninstall-manifest.sh # unit: install/uninstall symmetry
gen-docs-drift.sh # integration: make gen && git diff --check
lint-gate.sh # integration: make lint exit code
gpg-backup.sh # unit: passphrase not in argv (mocked gpg)
config-precedence.sh # unit: env-wins-over-file precedence
unsupported-flags.sh # unit: pos-ai-server unsupported flag handling
ai-llama-detect.sh # unit: llama version detection (mocked binary)
Runner (tests/run-tests.sh):
#!/usr/bin/env bash
set -euo pipefail
# Minimal test runner — sources test functions, counts pass/fail/skip.
# Usage: tests/run-tests.sh [test-file ...]
# If no args, runs all tests/*.sh files.
PASS=0; FAIL=0; SKIP=0
check() { local desc="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then PASS=$((PASS+1)); printf ' PASS %s\n' "$desc"
else FAIL=$((FAIL+1)); printf ' FAIL %s (expected=%s actual=%s)\n' "$desc" "$expected" "$actual"; fi }
skip() { SKIP=$((SKIP+1)); printf ' SKIP %s (%s)\n' "$1" "$2"; }
# ... file discovery, per-file sourcing, summary ...
make test target (Makefile addition):
test:
./tests/run-tests.sh
Interaction with existing gates:
make checkandmake lintremain unchanged — they are static gates (syntax, exec bits, doc sync, convention).make testis a separate target for behavioral regression tests.- CI (
.gitea/workflows/lint.yml) does NOT need to runmake testin this pass — that is a future CI enhancement. The tests exist for local validation and are committed as the regression baseline.
Determinism and speed:
- All tests use stub PATH (temp dir with fake binaries) and env-overridable paths per
DEV.md:198. - No network calls, no real systemd, no real llama-server binary.
- Target: all tests complete in < 60 seconds on a typical dev box.
- Tests that need
systemd-analyze(for real validation) use a skip-if-unavailable pattern:command -v systemd-analyze &>/dev/null || { skip "systemd-analyze validation" "systemd-analyze not available"; return 0; } - Tests that need
jq(for JSON assertions) use the same skip pattern. - Skip means "not applicable in this environment" — never "test passed." Tests never lie about pass/fail.
What is unit-testable with stub PATH vs integration-only:
| Test | Method | Skip condition |
|---|---|---|
| AI server flag validation | Stub PATH with fake llama-server that echoes --help text |
Never (fake binary is self-contained) |
| AI HF single-file failure | Stub PATH with fake curl that returns rc=1 |
Never |
| Telegram auth gate | Direct function extraction (source the auth logic, call with test args) — or stub PATH with fake jq/curl |
Never |
| Matrix auth gate | Same as Telegram | Never |
| Config loader | Direct sourcing of load_env_file |
Never |
| Systemd unit generation | Stub PATH with fake systemd-escape, systemd-quote output comparison |
systemd-analyze not available → skip validation step |
| Uninstall manifest | Direct comparison of install list vs uninstall list (grep both scripts) | Never |
| Gen-docs drift | Run make gen then git diff --check |
git repo not available → skip |
| Lint gate | Run make lint and check exit code |
Never |
| GPG passphrase | Stub PATH with fake gpg that echoes argv to a log file; assert passphrase not in log |
Never |
| Config precedence | Export env var, write config file, call loader, assert env wins | Never |
| Unsupported flags | Stub PATH with fake llama-server that echoes specific --help text |
Never |
| Llama version detect | Stub PATH with fake llama-server that echoes --version |
Never |
Files affected
| File | Change |
|---|---|
tests/run-tests.sh (new) |
Test runner |
tests/ai-server-flags.sh (new) |
Flag validation tests |
tests/ai-hf-download.sh (new) |
HF single-file failure tests |
tests/telegram-auth.sh (new) |
Telegram auth gate tests |
tests/matrix-auth.sh (new) |
Matrix auth gate tests |
tests/config-loader.sh (new) |
Config loader precedence tests |
tests/systemd-unit.sh (new) |
Systemd unit generation tests |
tests/uninstall-manifest.sh (new) |
Install/uninstall symmetry tests |
tests/gen-docs-drift.sh (new) |
Gen-docs drift test |
tests/lint-gate.sh (new) |
Lint gate test |
tests/gpg-backup.sh (new) |
GPG passphrase tests |
tests/config-precedence.sh (new) |
Config precedence tests |
tests/unsupported-flags.sh (new) |
Unsupported flag handling tests |
tests/ai-llama-detect.sh (new) |
Llama version detection tests |
Makefile |
Add test target |
scripts/check-sync.sh |
(Optional) Add tests/*.sh to the bash -n scan list |
Acceptance criteria
make testruns all 14 test files and reports PASS/FAIL/SKIP counts.make testexits 0 when all tests pass (or are skipped).make testexits non-zero when any test FAILs.- No test makes network calls (verified by grepping test files for
curl/wget/httpwithout stub wrappers). - All tests complete in < 60 seconds (measured on a representative dev box).
make checkandmake lintare unaffected (0 FAIL, 0 WARN).make genis unaffected.- Tests that need
systemd-analyzeskip gracefully when unavailable (exit 0, report SKIP). - The test runner does not leave temp files in the working tree (uses
/tmpfor all temp dirs). - Each test file starts with
#!/usr/bin/env bash+set -euo pipefail(passesbash -nand lint).
Risks / open questions
- The test framework is minimal by design. It does not use bats, shunit2, or any external framework — consistent with the repo's zero-dependency philosophy. If the test surface grows beyond ~20 files, consider bats at that point.
- Some tests (auth gate, config loader) require extracting logic from tools into testable functions or sourcing the tool and overriding variables. This is the established pattern from DEV.md's stub-PATH approach.
- The gen-docs drift test depends on
make genbeing idempotent — which is a project invariant. - The lint gate test is inherently coupled to
scripts/lint-conventions.shbehavior — if lint rules change, this test may need updating. Acceptable; it is a regression canary.
D-D: Config Loader Centralization
Decision
Establish lib/config-ui.sh as the canonical config loader. Add a single generic load_env_file function and migrate all 9 hand-rolled loaders to it.
1. Add load_env_file to lib/config-ui.sh:
# Canonical env-file loader. Reads KEY=VALUE lines, strips comments and
# CRLF, applies env-wins precedence (exported env vars are never overwritten).
# Usage: load_env_file <file>
load_env_file() {
local f="$1" k v
[ -f "$f" ] || return 0
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in \#*) continue ;; esac
v="${v//$'\r'/}"
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$f" || true)
}
Precedence contract (documented, matches dominant behavior):
CLI flags > exported environment > config file values > defaults
This is the existing behavior in all 9 hand-rolled loaders. The load_env_file function implements "exported env wins over file" (the if [ -z "${!k:-}" ] check). CLI flags are applied later by the tool's own arg parser. Defaults are applied at variable declaration (${VAR:-default}). This contract is now explicit and single-sourced.
2. Deprecate load_system_env in lib/common.sh:146-159:
Make load_system_env delegate to load_env_file:
load_system_env() {
load_env_file "$HOME/.config/linux_post_install/system.env"
}
This requires common.sh to source config-ui.sh. However, common.sh is the base library sourced by most tools, and config-ui.sh is not currently sourced by common.sh. Decision: do NOT make common.sh source config-ui.sh. Instead, keep load_system_env as-is (it is functionally identical to load_env_file) but note in config-ui.sh that load_env_file supersedes it. New tools use load_env_file; existing load_system_env callers continue working. This avoids a circular dependency risk and a mass-change to common.sh consumers.
The real migration targets are the 9 tools that hand-roll their own load_config().
3. CRLF policy (universal strip):
load_env_file strips \r unconditionally (v="${v//$'\r'/}"). This resolves the 5-tool vs 5-tool CRLF divergence identified in the audit. All 5 tools that currently strip CRLF will continue to work; the 5 that don't will now strip it too (defensive improvement, no behavioral regression).
4. XDG honoring:
load_env_file takes an absolute file path — it does not resolve CONFIG_DIR. The caller passes the full path. For tools that currently hardcode $HOME/.config/linux_post_install/..., the migration replaces the hardcoded path with ${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}/<file>. Since common.sh defines CONFIG_DIR at line 19, tools sourcing common.sh already have it; the self-contained communication tools already have their own CONFIG_DIR guarded copy.
Migration list (9 tools):
| Tool | Current function | Current file | Notes |
|---|---|---|---|
bin/pos-communication-telegram-sender |
load_config:61-74 |
telegram.env |
Already sources config-ui.sh for cfg_value |
bin/pos-communication-telegram-listener |
load_config:86-98 |
telegram.env |
Standalone |
bin/pos-communication-matrix-listener |
load_config:65-77 |
matrix.env |
Standalone |
bin/pos-communication-matrix-sender |
load_config:44-57 |
matrix.env |
Standalone |
bin/pos-communication-scrcpy |
load_config:14-28 |
scrcpy.env |
Standalone |
bin/pos-ai |
load_config:130-160 |
ai.env + legacy files |
Has extra legacy-file loop |
bin/pos-ai-server |
load_config:21-35 |
ai.env |
Standalone |
bin/pos-ai-hf |
load_hf_config:30-44 |
ai.env |
Standalone |
bin/pos-media-grab |
load_grab_config:10-23 |
grab.env |
Standalone |
For each tool: source config-ui.sh (with the existing fallback chain pattern), replace the hand-rolled function body with load_env_file "$CONFIG_FILE", preserve any tool-specific extras (like pos-ai's legacy-file loop — keep that as a second load_env_file call on the legacy path).
5. Collapse entertainment-lib read/write into config-ui:
entertainment-lib.sh's config_value and write_config_key (lines 28-54) are functionally identical to cfg_value and cfg_write in config-ui.sh (lines 311-344). Decision: add thin wrappers in entertainment-lib.sh that delegate to cfg_value/cfg_write:
config_value() {
local k="$1"
cfg_value "$CONFIG_FILE" "$k"
}
write_config_key() {
local key="$1" val="$2"
cfg_write "$CONFIG_FILE" "$key" "$val"
}
This preserves the existing config_value/write_config_key API for the 5 entertainment tools that call them, while the implementation lives in one place. entertainment-lib.sh must source config-ui.sh (add to its source chain).
Files affected
| File | Change |
|---|---|
lib/config-ui.sh |
Add load_env_file function |
bin/pos-communication-telegram-sender |
Replace load_config body with load_env_file |
bin/pos-communication-telegram-listener |
Replace load_config body with load_env_file |
bin/pos-communication-matrix-listener |
Replace load_config body with load_env_file |
bin/pos-communication-matrix-sender |
Replace load_config body with load_env_file |
bin/pos-communication-scrcpy |
Replace load_config body with load_env_file |
bin/pos-ai |
Replace load_config body with load_env_file + legacy loop |
bin/pos-ai-server |
Replace load_config body with load_env_file |
bin/pos-ai-hf |
Replace load_hf_config body with load_env_file |
bin/pos-media-grab |
Replace load_grab_config body with load_env_file |
lib/entertainment-lib.sh |
Delegate config_value/write_config_key to cfg_value/cfg_write |
DOC/DEV.md |
Update "No shared lib? Inline fallbacks" section to reference config-ui.sh as the canonical loader |
Acceptance criteria
grep -rn 'while IFS.*read.*k.*v' bin/pos-communication-* bin/pos-ai* bin/pos-media-grabreturns zero hand-rolled loader matches (all replaced).- Each migrated tool passes its existing behavioral expectations: env-wins precedence, CRLF stripping, missing-file graceful return (rc 0).
pos config telegramandpos config matrixdisplay and mask values correctly after migration.entertainment-lib.shconfig_valueandwrite_config_keystill work (entertainment tools pass their tests).make gen && make check && make lintall pass at 0 FAIL, 0 WARN.- No tool that previously sourced
common.shnow additionally sourcesconfig-ui.shunless it was already doing so. Tools that were standalone (communication tools) now sourceconfig-ui.shvia the fallback chain, which is the same pattern used by their existing inline copies.
Risks / open questions
- Self-contained tools sourcing config-ui.sh: The 5 communication tools currently do NOT source any shared lib (they carry inline fallbacks). After migration, they source
config-ui.sh. This is a net improvement (shared implementation) but changes the "no shared lib" classification of these tools. Mitigation: the inline fallback copies oflog/warn/errstay in place; only theload_configbody is replaced. The tools remain self-contained for basic logging; they just share the config loader. - pos-ai's legacy file loop:
pos-ai:130-160readsai.envplus legacygemini.envandopenrouter.envfiles. This is tool-specific logic; keep it as a second pass afterload_env_file "$CONFIG_FILE". Do not try to generalize the legacy loop intoload_env_file. - Regression risk: Each migration touches a working tool's config loading. The test suite (D-C) covers config precedence, which mitigates this.
D-E: Install/Uninstall Manifest
Decision
Close the install-only gaps in pos-system-uninstall with the smallest targeted fixes. Do NOT introduce a full manifest abstraction (like lib/install-manifest.sh) — that would be a larger refactor inconsistent with the current "list-based" approach in both install.sh and pos-system-uninstall.
1. Add 9 orphaned libs to Tier 1 scan and removal:
Extend the lib list at bin/pos-system-uninstall:62-64 (scan) and :238-239 (remove) from:
for f in common.sh menu-lib.sh share-lib.sh; do
to:
for f in common.sh menu-lib.sh share-lib.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 registry.sh; do
Same change in both scan_tier1 and remove_tier1.
2. Add ScaleTail directory and feature-flag store to Tier 1 removal:
After the lib removal block in remove_tier1:
# ScaleTail templates
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
Add corresponding scan entries in scan_tier1:
[ -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/")
3. Add USER systemd unit discovery to Tier 1:
After the system-unit removal block (:287-311), add:
# USER systemd units (pos-* and pos-entertainment-* and pos-schedule-*)
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
local uname
uname="$(basename "$ufile" .service)"
uname="${uname%.timer}"
systemctl --user disable --now "${basename "$ufile"}" 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
Add corresponding scan in scan_tier1:
local user_unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
if [ -d "$user_unit_dir" ]; then
while IFS= read -r ufile; do
found+=("user-unit: $(basename "$ufile")")
done < <(find "$user_unit_dir" -maxdepth 1 -name 'pos-*' -type f 2>/dev/null || true)
fi
4. De-hardcode entertainment-plugin and prebuilt-binary lists:
Replace the hardcoded loops for entertainment plugins (:72-74 scan, :248-250 remove) with directory-driven discovery (matching install.sh's pattern):
# Scan: discover plugins by POS_PLUGIN marker
while IFS= read -r f; do
found+=("$f")
done < <(for ep in /usr/local/bin/*.sh; do
grep -q '^# POS_PLUGIN:' "$ep" 2>/dev/null && echo "$ep"
done | sort)
# Remove: all installed entertainment plugins (discovered by POS_PLUGIN marker)
while IFS= read -r f; do
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
done < <(for ep in /usr/local/bin/*.sh; do
grep -q '^# POS_PLUGIN:' "$ep" 2>/dev/null && echo "$ep"
done | sort)
5. Fix the .bash_completion over-broad removal (H-002):
Replace sed -i '/pos/d' at :333 with marker-based or anchored patterns:
# Only remove lines the installer added (source pos.bash pattern)
sed -i '/source.*pos\.bash/d' "$HOME/.bash_completion"
This is conservative: only lines that postinstall.sh would have added are removed. If no such lines exist, nothing is touched.
6. Fix .bashrc removal anchoring (H-003):
Add word-boundary anchors to the sed patterns at :320-322:
sed -i '/source.*pos-ai-hook\.sh/d' "$HOME/.bashrc"
sed -i '/linux_post_install.*PATH.*pos/d' "$HOME/.bashrc"
sed -i '/source.*pos\.bash/d' "$HOME/.bashrc"
7. Document deliberately surviving artifacts:
Add a comment block in pos-system-uninstall's usage() and in the scan output:
# Intentionally NOT removed (user-managed):
# - apt packages (system packages)
# - /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)
Files affected
| File | Change |
|---|---|
bin/pos-system-uninstall:62-64 |
Add 9 libs to scan list |
bin/pos-system-uninstall:238-239 |
Add 9 libs to remove list |
bin/pos-system-uninstall:248-250 |
De-hardcode entertainment plugins |
bin/pos-system-uninstall:260-262 |
Keep prebuilt binaries (already correct, keep as-is for now) |
bin/pos-system-uninstall (after 311) |
Add ScaleTail dir + flags dir removal |
bin/pos-system-uninstall (after 311) |
Add USER systemd unit scan + removal |
bin/pos-system-uninstall:72-74 |
De-hardcode entertainment plugin scan |
bin/pos-system-uninstall:333 |
Fix .bash_completion over-broad removal |
bin/pos-system-uninstall:320-322 |
Fix .bashrc anchoring |
bin/pos-system-uninstall (usage) |
Document deliberately surviving artifacts |
Acceptance criteria
scan_tier1shows all 12 libs (3 original + 9 new) when installed.remove_tier1removes all 12 libs from/usr/local/bin/.- After
remove_tier1,/usr/local/share/linux_post_install/is empty or removed. - After
remove_tier1, nopos-*user units remain in~/.config/systemd/user/. .bashrcremoval does not remove lines unrelated to pos (verified by test: a.bashrcwithsource pos.bashin a comment is not affected)..bash_completionremoval does not remove lines unrelated to pos (thesed '/pos/d'is replaced with anchored patterns).make gen && make check && make lintall pass at 0 FAIL, 0 WARN.- The install manifest test (D-C
tests/uninstall-manifest.sh) confirms: every artifact in the install scan has a corresponding removal path.
Risks / open questions
- USER systemd unit removal during
remove_tier1: This callssystemctl --userwhich may fail if no user session is available (e.g., running uninstall as a scheduled task). Mitigation: wrap in2>/dev/null || true(idempotent, like the system-unit path). - Prebuilt binaries list is still hardcoded (
wihotspot,wihotspot-gui,create_ap). De-hardcoding this would require a marker system similar to POS_PLUGIN, which is overengineering for 3 binaries. Keep as-is for now; document as a known limitation. /usr/local/share/linux_post_installparent directory is only removed if empty after removingscale-tailandflags/. This is safe because the parent is also the ScaleTail clone destination.
D-F: pos-ai-server Validation Semantics
Decision
Expand flag validation to cover every flag that will appear in ExecStart, not just CLI-explicit ones. Distinguish between "requested" (user or config intent) and "default" (tool-emitted) flags with different error behavior.
1. Expand validation scope to all ExecStart flags:
Currently, only REQUESTED_FLAGS (CLI-parsed at :295-359) is validated at :407-409. The always-emitted defaults (--port, --host, --n-gpu-layers, --ctx-size, --threads) and config-sourced optional flags (--gpu-threads, --tensor-split, --batch-size, etc.) are emitted into exec_cmd at :445-490 without validation.
After this change, all flags in exec_cmd are validated. The validation split:
- Requested flags (CLI OR config-sourced): If unsupported, hard error. The user explicitly asked for something that doesn't work — fail loudly.
- Default flags (emitted without user intent): If unsupported, warn + omit. The tool chose a default that this build doesn't support — silently degrade.
2. How config-sourced flags get marked "requested":
Add config-sourced flags to REQUESTED_FLAGS during config loading. After load_config in cmd_start (around :395), read the config file for keys that map to CLI flags and add them to REQUESTED_FLAGS:
# After load_config, mark config-sourced flags as requested
if [ -n "${LLAMACPP_PORT:-}" ] && [ "$PORT" != "8088" ]; then
REQUESTED_FLAGS+=("--port")
fi
if [ -n "${LLAMACPP_HOST:-}" ] && [ "$HOST" != "127.0.0.1" ]; then
REQUESTED_FLAGS+=("--host")
fi
# ... similar for other config keys that map to flags
Wait — this is too fragile (needs manual comparison with defaults). Better approach: introduce a CONFIG_REQUESTED_FLAGS array that is populated during load_config when a config key that maps to a flag is actually set in the file:
CONFIG_REQUESTED_FLAGS=()
load_config() {
local f="$CONFIG_FILE"
[ -f "$f" ] || return 0
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in \#*) continue ;; esac
v="${v//$'\r'/}"
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
# Track config keys that map to flags
case "$k" in
LLAMACPP_PORT) CONFIG_REQUESTED_FLAGS+=("--port") ;;
LLAMACPP_HOST) CONFIG_REQUESTED_FLAGS+=("--host") ;;
LLAMACPP_CTX_SIZE) CONFIG_REQUESTED_FLAGS+=("--ctx-size") ;;
LLAMACPP_GPU_LAYERS) CONFIG_REQUESTED_FLAGS+=("--n-gpu-layers") ;;
LLAMACPP_THREADS) CONFIG_REQUESTED_FLAGS+=("--threads") ;;
# ... other config-to-flag mappings
esac
fi
done < <(grep -E '^[A-Z_]+=' "$f" || true)
}
Then in cmd_start:
# Merge CLI-requested and config-requested flags
ALL_REQUESTED_FLAGS=("${REQUESTED_FLAGS[@]}" "${CONFIG_REQUESTED_FLAGS[@]}")
# Deduplicate
local deduped=()
for flag in "${ALL_REQUESTED_FLAGS[@]}"; do
case " ${deduped[*]:-} " in *" $flag "*) continue ;; esac
deduped+=("$flag")
done
# Validate requested flags (hard error on unsupported)
if [ "${#deduped[@]}" -gt 0 ]; then
validate_requested_flags "$llamacpp_bin" "$version" "${deduped[@]}"
fi
# Validate default flags (warn + omit on unsupported)
validate_default_flags "$llamacpp_bin" "$version"
3. New validate_default_flags function:
# validate_default_flags <binary> <version> — for every flag that will be
# emitted by default (without user intent), check it exists in --help.
# Unsupported defaults are silently omitted from exec_cmd.
# Sets global flags: DEFAULT_PORT_OK, DEFAULT_HOST_OK, etc.
DEFAULT_FLAGS_VALIDATED=0
validate_default_flags() {
local bin="$1" version="$2"
local help_text
help_text="$("$bin" --help 2>/dev/null)" || {
warn "Cannot obtain llama-server --help output — skipping default flag validation"
return 0
}
DEFAULT_PORT_OK=1; DEFAULT_HOST_OK=1; DEFAULT_CTX_OK=1
DEFAULT_GPU_OK=1; DEFAULT_THREADS_OK=1
local check_flag() {
local flag="$1" varname="$2"
if ! printf '%s' "$help_text" | grep -qF -- "$flag"; then
warn "installed llama.cpp ${version} does not support default flag ${flag} — omitting"
eval "$varname=0"
fi
}
check_flag "--port" DEFAULT_PORT_OK
check_flag "--host" DEFAULT_HOST_OK
check_flag "--ctx-size" DEFAULT_CTX_OK
check_flag "--n-gpu-layers" DEFAULT_GPU_OK
check_flag "--threads" DEFAULT_THREADS_OK
DEFAULT_FLAGS_VALIDATED=1
}
Then in exec_cmd construction (:445-490), wrap each default flag in a validation check:
exec_cmd="$(systemd_quote "$llamacpp_full") -m $(systemd_quote "$model")"
[ "$DEFAULT_PORT_OK" -eq 1 ] && exec_cmd+=" --port $PORT"
[ "$DEFAULT_HOST_OK" -eq 1 ] && exec_cmd+=" --host $HOST"
[ "$DEFAULT_GPU_OK" -eq 1 ] && exec_cmd+=" --n-gpu-layers $gpu_layers"
[ "$DEFAULT_CTX_OK" -eq 1 ] && exec_cmd+=" --ctx-size $CTX_SIZE"
[ "$DEFAULT_THREADS_OK" -eq 1 ] && exec_cmd+=" --threads $THREADS"
4. --help unreadable → single warn + proceed (unchanged):
The existing help_text fetch at :72-75 already handles this: warn "Cannot obtain llama-server --help output — skipping flag validation"; return 0. This behavior is preserved for both validate_requested_flags and validate_default_flags.
5. Make detected version inform error messaging (no version branching):
The version is already interpolated into error strings (:84): "installed llama.cpp ${version} does not expose ${flag}". This is correct and sufficient. No version-conditional logic or compat table is added. The version is informational for the user's debugging, not a branching variable.
6. Fix the substring matching issue (D4, low priority):
Replace grep -qF -- "$flag" at :83 with a word-boundary match:
if ! printf '%s' "$help_text" | grep -qE -- "(^|[[:space:]])${flag}($|[[:space:]])"; then
This prevents false-positive substring matches (e.g., --mmap matching --no-mmap if such a flag existed). Low priority but trivial to fix during this pass.
Files affected
| File | Change |
|---|---|
bin/pos-ai-server:21-35 |
Extend load_config to populate CONFIG_REQUESTED_FLAGS |
bin/pos-ai-server:66-87 |
Fix validate_requested_flags substring matching (D4) |
bin/pos-ai-server (new function) |
Add validate_default_flags |
bin/pos-ai-server:407-409 |
Extend validation to merge CLI + config requested flags |
bin/pos-ai-server:445-490 |
Wrap default flags in DEFAULT_*_OK guards |
DOC/POS.md (ai server section) |
Document validation behavior: requested → hard error, default → warn+omit |
Acceptance criteria
- A config file with
LLAMACPP_PORT=9999causes--portto be added toREQUESTED_FLAGSand validated against--help. - If the binary's
--helpdoes not list--port, the server refuses to start with an error naming the flag and the detected version. - If the binary's
--helpdoes not list--threads(a default),--threadsis omitted fromExecStartwith a warning. - If
--helpcannot be read, all flags are accepted (warn + proceed, unchanged behavior). - The
grep -qFsubstring issue (D4) is resolved —--mmapno longer matches if only--no-mmapappears in--help. - Generated
ExecStartcontains only flags that pass validation. make gen && make check && make lintall pass at 0 FAIL, 0 WARN.
Risks / open questions
- The baseline-compatible flag set (
--port,--host,--n-gpu-layers,--ctx-size,--threads) is assumed universal across modern llama.cpp builds. If a very old build lacks even--port, the server would start withExecStartcontaining only the binary and model — functional but unusual. This is an acceptable degradation path. - The
CONFIG_REQUESTED_FLAGSapproach requires manually maintaining the config-key-to-flag mapping inload_config. If a new config key is added, the mapping must be updated. This is a small maintenance burden; document the mapping clearly. - The default-flags guard changes the ExecStart generation logic from unconditional to conditional. If
validate_default_flagshas a bug, the ExecStart could be missing expected flags. The test suite (D-C) covers this case.
Recommended Execution Order
The decisions have dependencies and independent tracks. Here is the recommended order:
Phase 1: Infrastructure (no behavior changes, green gates)
- D-C (Test framework) — Create
tests/+make test+ runner. Write skeleton test files withskipfor features not yet implemented. Verifymake testruns and exits 0 (all skipped). This establishes the regression safety net for all subsequent changes. - D-D part 1 (Config loader) — Add
load_env_filetolib/config-ui.sh. Do NOT migrate tools yet. Verify gates green.
Phase 2: Security fixes (highest priority, isolated files)
- D-A (Telegram/Matrix auth) — The Telegram fix is self-contained in
bin/pos-communication-telegram-listener+bin/pos-communication-telegram-senderPOS_CONFIG header + config template. The Matrix fix is a one-line guard inbin/pos-communication-matrix-listener. Both are isolated. - D-B (AI eval posture) — Flip the confirmation default and add
--no-command-executionflag. Touchesbin/pos-ai+ both listeners' AI bridge calls. Independent of D-A (different lines in the listeners).
Phase 3: Validation and correctness
- D-F (pos-ai-server validation) — Extends the validation in
bin/pos-ai-server. Depends on D-D being complete (the config loader migration should happen first soload_configinpos-ai-serveris already migrated — or do D-F's config-requested-flags addition within the hand-rolled loader before migration, then adjust during D-D migration). Decision: do D-F first (it modifies the hand-rolled loader inpos-ai-server), then D-D migration replaces it. This avoids double-touching. - D-E (Install/uninstall manifest) — Self-contained in
bin/pos-system-uninstall. Independent of all others except D-C (the uninstall test depends on the manifest being complete).
Phase 4: Config loader migration
- D-D parts 2-5 (Migrate loaders, collapse entertainment-lib) — Migrate the 9 tools' hand-rolled loaders to
load_env_file. This touches many files but each change is mechanical. Do it last so all other changes (D-A, D-B, D-F) are already in place and their config keys are stable.
Phase 5: Validation
- Run full gate suite:
make gen && make check && make lint— verify 0 FAIL, 0 WARN. - Run
make test— verify all tests pass (no more skips for implemented features).
Builder-track split (parallel-safe groups)
Group A (communication tools): D-A Telegram + D-A Matrix → single Builder, one commit Group B (AI tools): D-B (pos-ai) + D-F (pos-ai-server) → single Builder, one commit Group C (infrastructure): D-C (tests) + D-D (config-ui.sh + loader) → single Builder, one commit Group D (uninstaller): D-E → single Builder, one commit Group E (migration): D-D tool migrations (after A+B+D complete) → single Builder, one commit
Groups A, B, C, and D are parallel-safe (no file overlap). Group E depends on B and D completing (to avoid merge conflicts in pos-ai-server and pos-communication-* tools).
Open Items (require future decisions, not in this pass)
- Trusted-group mode for Telegram: Explicit opt-in
TELEGRAM_GROUP_MODE=truewith per-user allowlist. Deferred — the current AND-gate is sufficient for the primary use case (1:1 private chat with the owner). - Lint performance optimization: The Explorer identified ~17 hotspots in
scripts/lint-conventions.sh(Task 1). This is a performance improvement, not a correctness fix — separate from the stabilization pass. - LLAMACPP_HOST coherence (D2 from AI audit): The server honors
LLAMACPP_HOSTbut the llamacpp provider adapter and probes hardcode127.0.0.1. This is a behavioral coherence issue, not a security issue. Deferred — it requires changes tolib/ai-providers/llamacpp.shand the server's probe paths, which is a feature-level fix. - pos-ai-hf single-file download failure (D1 from AI audit): The single-file download path does not record failures in
failed_files, so.hf-metais written for a partially-downloaded model. This is a HIGH defect but is a pure bug fix (not an architectural decision) — belongs in Builder scope directly, not in this design pass. - GPG passphrase in argv (V3 from Security audit): Fixing this requires
gpg --batch --passphrase-fdor a temp-file approach. Behavioral change tobin/pos-system-backup— defer to a separate fix commit. - H-002/H-003 bashrc/bash_completion over-broad removal: Partially addressed in D-E (
.bash_completionfix) but a more thorough marker-based approach would be ideal. The D-E fix is the minimum viable improvement.
2026-09-06 (post-merge) — llama-server start breakage root cause + fix decisions
References: AgentsReport/detective/2026-09-06_ai-server-breakage.md (root cause, classified FACT); full decisions in AgentsReport/architect/2026-09-06_ai-server-fix-design.md.
Summary: A user's pos ai server start failing after bash apps/install.sh llamacpp traced to four independent defects in bin/pos-ai-server (plus one adapter-default interaction): (1) detect_llama_version:71 discards stderr, and llama.cpp b10822 prints its version to stderr → always "unknown"; (2) printf|grep -q under set -o pipefail → SIGPIPE race (rc=141) → flags randomly rejected; (3) resolve_model:229-259 rejects the HF-downloader's <dir>/<file>.gguf layout; (4) unit write precedes an unguarded systemctl --user daemon-reload:605 that fails under SSH w/o XDG_RUNTIME_DIR → orphaned unit. Port pinning (llama.cpp default 8080 vs tool/adapter 8088) is downstream of the race.
Decisions added (DQ1-DQ6, see the fix-design file for full rationale): help-gated validation retained (no version→capability map; version is cosmetic); resolve_model expands a single-gguf dir (exact precedence, no silent multi-pick); user-bus pre-flight via new ensure_user_bus in lib/common.sh + --no-unit direct-run on pos-ai-server; find_llamacpp narrowed to llama-server/llama-server-cuda; --port $PORT always pinned; installer post-install sanity in scope. Ratified fix scope F1-F7, all KEEP (F4 adjusted to add --no-unit + shared helper; F2's SIGPIPE mechanism supersedes the earlier D-F's descriptive reliance on the broken pipeline). Deferred: adapter dual-port probing fallback, direct-run for matrix-listener/network-download, and the previously-tracked D1/D2 AI-server defects.
Consistency with prior decisions (no D-A..D-F text altered above): D-F's requested-vs-default validation semantics (requested → hard err, default → warn+omit) remains the contract; F2 fixes the mechanism that made D-F's help-gate non-deterministic, and F6 (port pinning) is a direct realization of D-F's always-emitted-defaults guarantee. The bus pre-flight (F4) extends what scheduler-lib.sh:614 already does (a query guard) into a hard pre-write enable guard shared across the three tools that write user units.