Compare commits

..
2 Commits
Author SHA1 Message Date
Gmer4Lfe 0eccd7b36e File the repair log settings under the section that documents them
They sat under AI Web Search, which only mattered once the settings card
started grouping by the header above a key.
2026-08-11 18:36:37 -04:00
Gmer4Lfe a95def9139 Let the conf say which control each setting deserves
The comments above a setting already state its choices, units and bounds, so the
form can read them instead of asking for the file to be annotated first.
2026-08-11 18:17:04 -04:00
3 changed files with 352 additions and 8 deletions
+5 -5
View File
@@ -1877,6 +1877,11 @@
# Both are bounded by time (since the last pass) and by a line cap, so a flood costs one pass.
# Tools/ai_log_check.sh replays this host's real logs against the patterns — run it after
# changing any of them.
AI_REPAIR_SYSLOG_ENABLED=true
AI_REPAIR_SYSLOG_MAX_LINES=4000
AI_REPAIR_CONTAINER_LOGS_ENABLED=true
AI_REPAIR_CONTAINER_LOG_LINES=400
# ━━━ AI Web Search ━━━
# General Chat only. Every other profile either reads this installation or changes it; chat holds
# no capability at all, and that is exactly why searching is the one thing it may do — a read that
@@ -1904,11 +1909,6 @@
AI_WEB_SEARCH_RESULTS=4
AI_WEB_SEARCH_TIMEOUT=6
AI_REPAIR_SYSLOG_ENABLED=true
AI_REPAIR_SYSLOG_MAX_LINES=4000
AI_REPAIR_CONTAINER_LOGS_ENABLED=true
AI_REPAIR_CONTAINER_LOG_LINES=400
# ━━━ AI Conf Write Access ━━━
# Separate switch from AI_ENABLED, off by default, and an explicit key whitelist. Never paths,
# never credentials, never a container name. An empty whitelist means no writes regardless of
+210
View File
@@ -0,0 +1,210 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Checks how the settings UI will draw every conf field, and proves the inference rules against
// the conventions the conf files actually use. Two modes in one tool: fixed assertions over
// synthetic snippets, and a report of what this host's real master.conf infers to.
//
// OPERATIONAL MODEL
// Hand-run, reads only, writes nothing — the same shape as Tools/ai_log_check.sh. Run it after
// touching _vv_conf_widget(), after adding a conf convention, or when a setting draws as the
// wrong control and the question is whether the rule or the comment above it is at fault.
//
// php Tools/conf_widget_check.php assertions, then the live summary
// php Tools/conf_widget_check.php --list every live field and its inferred control
//
// WHY IT ASSERTS AGAINST SNIPPETS AND NOT THE LIVE CONF
// The live conf is the thing being described, so it cannot also be the thing that proves the
// description right — an inference rule that silently stopped matching would keep passing as
// the conf drifted to suit it. The snippets are frozen copies of each convention as written,
// so a rule change that breaks one shows up here rather than as a wrong control on a page.
//
// WHAT AN INFERENCE IS NOT
// Consistent with confform.php, none of this validates. A number field carrying min and max is
// a courtesy to whoever is typing, not a promise the value is sensible — the consuming script
// still owns that question.
//
// DEPENDS ON
// include/confform.php _vv_conf_parse_field_range(), vv_conf_key_is_secret()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/../include/confform.php';
$pass = 0; $fail = 0; $failed = [];
function check(string $name, $got, $want): void {
global $pass, $fail, $failed;
if ($got === $want) { $pass++; return; }
$fail++;
$failed[] = sprintf("%-42s got %s want %s", $name, json_encode($got), json_encode($want));
}
// Parse one snippet and return its fields keyed by name.
function vv_wc_fields(string $snippet): array {
$lines = explode("\n", $snippet);
$out = [];
foreach (_vv_conf_parse_field_range($lines, 0, count($lines), 'master.conf') as $f) {
$out[$f['key']] = $f;
}
return $out;
}
// ── Booleans and numbers ─────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
AI_ENABLED=true
AI_OFF=false
AI_QUOTED_BOOL="true"
AI_CONNECT_TIMEOUT=5 # seconds — probe when resolving which node has Ollama
AI_CHAT_HISTORY_MAX=10 # conversations kept — clamped to 1-50
AI_PLAIN_NUMBER=32
AI_NEGATIVE=-1
CONF);
check('bool true', $f['AI_ENABLED']['widget'], 'bool');
check('bool false', $f['AI_OFF']['widget'], 'bool');
check('bool quoted', $f['AI_QUOTED_BOOL']['widget'], 'bool');
check('int', $f['AI_CONNECT_TIMEOUT']['widget'], 'int');
check('int unit', $f['AI_CONNECT_TIMEOUT']['unit'] ?? null, 'seconds');
check('int without a unit', $f['AI_PLAIN_NUMBER']['unit'] ?? null, null);
check('int range min', $f['AI_CHAT_HISTORY_MAX']['min'] ?? null, 1);
check('int range max', $f['AI_CHAT_HISTORY_MAX']['max'] ?? null, 50);
check('int range keeps unit', $f['AI_CHAT_HISTORY_MAX']['unit'] ?? null, 'conversations');
check('negative int', $f['AI_NEGATIVE']['widget'], 'int');
// ── File modes must never become spinners ────────────────────────────────────────────────────
// A spinner invites arrowing 755 to 756, and a number input normalises a leading zero away.
$f = vv_wc_fields(<<<'CONF'
PERMISSIONS_DIR_MODE="755" # directories
PERMISSIONS_FILE_MODE="664" # files
TRANSCODE_CHMOD="755"
LEADING_ZERO_MODE="0755"
TRANSCODE_MANAGER_MODE="smart"
SOME_TIMEOUT_MODE=30 # seconds
CONF);
check('dir mode is text', $f['PERMISSIONS_DIR_MODE']['widget'], 'text');
check('file mode is text', $f['PERMISSIONS_FILE_MODE']['widget'], 'text');
check('chmod is text', $f['TRANSCODE_CHMOD']['widget'], 'text');
check('octal mode is text', $f['LEADING_ZERO_MODE']['widget'], 'text');
check('worded mode is text', $f['TRANSCODE_MANAGER_MODE']['widget'], 'text');
// The guard is about octal, not about the word MODE — a real duration is still a number.
check('non-octal MODE is int', $f['SOME_TIMEOUT_MODE']['widget'], 'int');
// ── Enum, quoted-line convention ─────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
# Controls behaviour when versions differ.
# "warn" — log warning and continue
# "abort" — refuse to continue (strict)
UNRAID_VERSION_MISMATCH_ACTION="warn"
CONF);
check('quoted-line enum', $f['UNRAID_VERSION_MISMATCH_ACTION']['widget'], 'enum');
check('enum choices', array_column($f['UNRAID_VERSION_MISMATCH_ACTION']['choices'], 'value'),
['warn', 'abort']);
check('enum keeps its hints', $f['UNRAID_VERSION_MISMATCH_ACTION']['choices'][1]['hint'],
'refuse to continue (strict)');
// One quoted comment line is a quotation, not a choice list of one.
$f = vv_wc_fields("# \"off\" means something here\n SOMETHING=\"off\"");
check('lone quoted line', $f['SOMETHING']['widget'], 'text');
// A value outside its own documented list is surfaced, never dropped to tidy the list — dropping
// it would change the setting the moment the form saved.
$f = vv_wc_fields(<<<'CONF'
# "warn" — log and continue
# "abort" — refuse
DRIFTED="something_else"
CONF);
check('drifted value stays enum', $f['DRIFTED']['widget'], 'enum');
check('drifted value offered', $f['DRIFTED']['choices'][0]['value'], 'something_else');
check('drifted list intact', count($f['DRIFTED']['choices']), 3);
// ── Enum, inline pipe convention ─────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
DIGEST_PROFILE="weekly" # always | smart | weekly
SONARR_MONITOR="all" # monitor mode on add: all | future | first | none
PROSE_WITH_PIPE="x" # pipe the output through grep | sort
CONF);
check('inline enum', $f['DIGEST_PROFILE']['widget'], 'enum');
check('inline enum choices', array_column($f['DIGEST_PROFILE']['choices'], 'value'),
['always', 'smart', 'weekly']);
check('inline enum after colon',$f['SONARR_MONITOR']['widget'], 'enum');
// The value is not among them, so this is prose that happens to contain a pipe.
check('prose with a pipe', $f['PROSE_WITH_PIPE']['widget'], 'text');
// ── Secrets ──────────────────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
HOST1_NPM_PASS="hunter2"
HOST1_SONARR_API_KEY="abcdef"
WEBHOOK_SECRET="s3cr3t"
AI_TOKEN_DB="${AI_DATA_DIR}/ai_token_history.db"
AI_TOKEN_RETAIN_ROWS=20000
AI_TOKEN_SYNC_ENABLED=true
CONF);
check('password masked', $f['HOST1_NPM_PASS']['widget'], 'secret');
check('api key masked', $f['HOST1_SONARR_API_KEY']['widget'], 'secret');
check('secret masked', $f['WEBHOOK_SECRET']['widget'], 'secret');
// LLM token accounting is not a credential. Masking these was the bug this exception fixes.
check('AI_TOKEN_DB visible', $f['AI_TOKEN_DB']['widget'], 'path');
check('AI_TOKEN_RETAIN visible', $f['AI_TOKEN_RETAIN_ROWS']['widget'], 'int');
check('AI_TOKEN_SYNC visible', $f['AI_TOKEN_SYNC_ENABLED']['widget'], 'bool');
// ── Paths, text, arrays ──────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
AI_INDEX_DB="${AI_DATA_DIR}/ai_index.db"
SOME_ABS="/mnt/user/appdata"
AI_MODEL="qwen3:14b"
AI_PROFILES="*"
AI_CONF_WRITE_KEYS=()
LIST_MULTI=(
one
two
)
CONF);
check('templated path', $f['AI_INDEX_DB']['widget'], 'path');
check('absolute path', $f['SOME_ABS']['widget'], 'path');
check('plain text', $f['AI_MODEL']['widget'], 'text');
check('wildcard text', $f['AI_PROFILES']['widget'], 'text');
check('single-line array', $f['AI_CONF_WRITE_KEYS']['widget'], 'lines');
check('multi-line array', $f['LIST_MULTI']['widget'], 'lines');
// The scratch field the parser uses to reach the comment lines must not ride out to the browser.
check('no _lines leak', isset($f['AI_MODEL']['_lines']), false);
printf("assertions: %d passed, %d failed\n", $pass, $fail);
foreach ($failed as $l) echo " FAIL $l\n";
// ── What this host's conf actually infers to ─────────────────────────────────────────────────
$path = CONF_DIR . '/master.conf';
if (!is_readable($path)) {
echo "\nmaster.conf not readable at $path — skipping the live summary\n";
exit($fail ? 1 : 0);
}
$lines = explode("\n", (string) file_get_contents($path));
$hdrs = [];
foreach ($lines as $i => $l) {
if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $l, $m)) $hdrs[] = [$i, trim($m[1])];
}
$list = in_array('--list', $argv, true);
$tally = []; $n = 0; $enums = []; $ranges = [];
foreach ($hdrs as $k => [$i, $name]) {
$end = $hdrs[$k + 1][0] ?? count($lines);
$rows = _vv_conf_parse_field_range($lines, $i + 1, $end, 'master.conf');
if ($list && $rows) printf("\n── %s\n", $name);
foreach ($rows as $x) {
$n++;
$w = $x['widget'] ?? '?';
$tally[$w] = ($tally[$w] ?? 0) + 1;
if ($w === 'enum') $enums[] = $x['key'] . ' = ' . implode(' | ', array_column($x['choices'], 'value'));
if (isset($x['min'])) $ranges[] = $x['key'] . ' ' . $x['min'] . '-' . $x['max'];
if ($list) {
$e = [];
if (isset($x['unit'])) $e[] = $x['unit'];
if (isset($x['min'])) $e[] = $x['min'] . '-' . $x['max'];
printf(" %-34s %-7s %s\n", $x['key'], $w, $e ? '[' . implode(' ', $e) . ']' : '');
}
}
}
printf("\nlive master.conf: %d fields across %d sections\n", $n, count($hdrs));
ksort($tally);
foreach ($tally as $k => $v) printf(" %-8s %d\n", $k, $v);
if ($enums) { echo "\nchoice lists found:\n"; foreach ($enums as $e) echo " $e\n"; }
if ($ranges) { echo "\nbounded numbers found:\n"; foreach ($ranges as $e) echo " $e\n"; }
exit($fail ? 1 : 0);
+137 -3
View File
@@ -234,6 +234,112 @@ function vv_conf_parse_subsection(string $raw, string $subName, string $filename
return _vv_conf_parse_field_range($lines, $start, $end, $filename) ?: null;
}
// Which control a field should be drawn with. Derived, never stored — the conf file stays the
// only schema, so this reads what is already written there rather than asking for the file to be
// annotated first. A key added tomorrow gets the right control with no code change and no markup.
//
// The order matters: an explicit statement in the comment beats a guess from the value, because
// the guess cannot tell "false" the boolean from "false" the string a script compares against.
//
// Nothing here validates. Consistent with the rest of this file, it decides shape and leaves
// correctness to the consuming script — a number field with min/max is a courtesy to whoever is
// typing, not a promise that the value is sensible.
function _vv_conf_widget(string $key, string $value, string $type, string $desc, array $descLines): array {
// Arrays get the block editor whatever they contain. One value per line is already the right
// control for a list, and there is no second guess worth making.
if ($type !== 'scalar') return ['widget' => 'lines'];
// Secrets never render in the clear, whatever their value looks like. Reuses the same rule
// the audit log redacts by, so a key cannot be a secret in the log and plain text in a form.
if (vv_conf_key_is_secret($key)) return ['widget' => 'secret'];
// The convention already in the confs: each choice on its own comment line, quoted, then a
// dash and what it does. It was written as documentation and happens to be a complete enum
// declaration, which is exactly the property this file is built around.
//
// # "warn" — log warning and continue
// # "abort" — refuse to continue (strict)
// UNRAID_VERSION_MISMATCH_ACTION="warn"
$choices = [];
foreach ($descLines as $line) {
if (preg_match('/^"([^"]+)"\s*(?:[—–-]\s*(.*))?$/u', trim($line), $m)) {
$choices[] = ['value' => $m[1], 'hint' => trim($m[2] ?? '')];
}
}
// A single quoted line is a quotation, not a choice list.
if (count($choices) > 1) {
// A value outside its own choice list is a misconfiguration, and dropping it to make the
// list tidy would change the setting the moment the form saved. It is offered as a choice
// instead, so the dropdown shows what is actually set and can also correct it.
$have = array_column($choices, 'value');
if (!in_array($value, $have, true)) {
array_unshift($choices, ['value' => $value, 'hint' => 'current value — not one of the documented options']);
}
return ['widget' => 'enum', 'choices' => $choices];
}
// The other convention already in the confs: the choices listed inline after the value, either
// as the whole comment or after a colon.
//
// DIGEST_PROFILE="weekly" # always | smart | weekly
// SONARR_DISCOVERY_MONITOR_MODE="all" # monitor mode on add: all | future | first | none
//
// Anchored to the end of the comment and required to contain the current value. The pattern is
// loose enough to match ordinary prose containing a pipe, and "the value is one of these" is
// the only signal that separates a choice list from a sentence.
if (preg_match('/(?:^|:\s*)([A-Za-z0-9_.\-]+(?:\s*\|\s*[A-Za-z0-9_.\-]+)+)\s*$/', trim($desc), $m)) {
$opts = array_map('trim', explode('|', $m[1]));
if (count($opts) > 1 && in_array($value, $opts, true)) {
return ['widget' => 'enum',
'choices' => array_map(fn($o) => ['value' => $o, 'hint' => ''], $opts)];
}
}
// The escape hatch, for a setting whose options are not already spelled out above it. Written
// to read as English so adding one does not turn the conf into a config language.
if (preg_match('/\bone of:\s*([^.;#]+)/i', $desc, $m)) {
$opts = array_values(array_filter(array_map('trim', preg_split('/\s*[,|]\s*/', $m[1])), 'strlen'));
if (count($opts) > 1) {
return ['widget' => 'enum',
'choices' => array_map(fn($o) => ['value' => trim($o, '"\''), 'hint' => ''], $opts)];
}
}
$v = strtolower(trim($value));
if ($v === 'true' || $v === 'false') return ['widget' => 'bool'];
// A file mode is digits and is not a number. A spinner invites arrowing 755 to 756, and a
// number input normalises a leading zero away — so 0755 would save as 755, which is the same
// mode today and a different one the day anything reads it as octal. Kept as plain text.
if (preg_match('/(CHMOD|_MODE|PERM|UMASK)/', $key) && preg_match('/^0?[0-7]{3}$/', trim($value))) {
return ['widget' => 'text'];
}
if (preg_match('/^-?\d+$/', trim($value))) {
$out = ['widget' => 'int'];
// "clamped to 1-50", "between 1 and 50". Already written in several confs, so the range
// that documents the setting also bounds the field.
if (preg_match('/(?:clamped to|between)\s*(-?\d+)\s*(?:-|to|and)\s*(-?\d+)/i', $desc, $r)) {
$out['min'] = (int) $r[1];
$out['max'] = (int) $r[2];
}
// The house style opens an inline comment with the unit — "# seconds — probe when
// resolving which node has Ollama" — so the unit is already there to be shown beside
// the box instead of buried in the description.
if (preg_match('/^\s*(seconds?|minutes?|hours?|days?|weeks?|months?|percent|%|[KMGT]B|'
. 'chars?|characters?|tokens?|conversations?|entries|items|lines|runs|'
. 'attempts|retries|consecutive)\b/i', $desc, $u)) {
$out['unit'] = strtolower($u[1]);
}
return $out;
}
// A path gets a wider box and no monospace surprises. ${VAR}/... is the established idiom.
if (str_starts_with($value, '/') || str_contains($value, '${')) return ['widget' => 'path'];
return ['widget' => 'text'];
}
// Parse all config fields between two line indices. Shared by vv_conf_parse_subsection()
// (per-script editor) and vv_conf_all_groups() (full settings view).
function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $filename): array {
@@ -255,6 +361,10 @@ function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $
}
$desc = implode(' ', $pendingDesc);
// Kept as lines as well as joined. The enum convention lives in the line breaks — one
// quoted choice per comment line — and joining them destroys the only thing that
// distinguishes a choice list from a paragraph that happens to contain quotes.
$descLines = $pendingDesc;
$pendingDesc = [];
// declare -A KEY=(
@@ -297,17 +407,30 @@ function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $
// Scalar: KEY="value" or KEY=value
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) {
$fields[] = ['key' => $m[1], 'value' => $m[2],
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename,
'_lines' => $descLines];
continue;
}
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) {
$val = trim($m[2]);
if ($val === '') continue;
$fields[] = ['key' => $m[1], 'value' => $val,
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename];
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename,
'_lines' => $descLines];
}
}
// Widget is added in one pass rather than at each of the five construction sites above, so
// every field type goes through the same rules and a new type cannot quietly skip them.
foreach ($fields as &$f) {
$f += _vv_conf_widget((string) $f['key'], (string) $f['value'], (string) $f['type'],
(string) $f['desc'], (array) ($f['_lines'] ?? []));
// Internal to this pass. Left in, it would ride out to the browser on every field as a
// second, subtly different copy of the description.
unset($f['_lines']);
}
unset($f);
return $fields;
}
@@ -367,8 +490,19 @@ function vv_conf_key_valid(string $key): bool {
}
// Credential-shaped keys are logged by name only. The audit log is the record that a change
// happened, not a second copy of the secret that changed.
// happened, not a second copy of the secret that changed. Also decides which fields render
// masked, so the same key cannot be redacted in the log and legible in a form.
//
// The match is deliberately loose — the second alternative is unanchored, so anything merely
// CONTAINING "token" or "pass" counts. Over-redacting a setting costs a little readability;
// under-redacting one puts a credential in a log file, so the bias is correct and stays.
//
// The exception exists because one family trips it on a word that means something else entirely:
// AI_TOKEN_* is LLM token accounting — a path, a row cap, a switch — and nothing there is a
// credential. Named as a literal prefix rather than by relaxing the rule, so this cannot widen
// into "token sometimes means tokens" and quietly expose a real one.
function vv_conf_key_is_secret(string $key): bool {
if (str_starts_with($key, 'AI_TOKEN_')) return false;
return (bool) preg_match('/(PASS|PASSWORD|SECRET|TOKEN|API_KEY|APIKEY|_KEY)$|(PASS|PASSWORD|SECRET|TOKEN|APIKEY)/i', $key);
}