From a8654280a7d02e68ef8430a9df99377981ec45e9 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 9 Aug 2026 19:23:57 -0400 Subject: [PATCH] Prove a repair before writing it, and gate writing separately from looking A model can be confident a port should be 8686; a probe can report that 8686 answered, and only the second is a fact. Detecting and repairing are also separate things to trust, so the feature runs read-only until the write switch is turned on under it. --- Deployment/master.conf.template | 18 +++ Plugin/unraid/include/ai_repair.php | 172 ++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index ef009af..7f62d29 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1764,6 +1764,20 @@ AI_ASSIST_CLEANUP=false # tier 2 — orphan and stuck-import triage AI_ASSIST_ONBOARD=false # tier 3 — onboarding / settings assistance +# ━━━ AI Repair ━━━ +# Reads the log of a job that has finished, turns known error shapes into findings, and offers +# or applies a repair. Two switches, because detecting and repairing are separate things to +# trust. +# +# AI_REPAIR_ENABLED on its own reads, files findings and offers fixes, and writes nothing. Run +# it there first — long enough to read what it finds and disagree with some of it. +# +# AI_REPAIR_AUTOFIX_ENABLED is what allows a value to be written unattended, and only ever a +# value a probe has answered on. Never a toggle: whether something should be switched on is a +# decision about intent, and a probe cannot prove intent the way it can prove a port answers. + AI_REPAIR_ENABLED=false + AI_REPAIR_AUTOFIX_ENABLED=false + # ━━━ AI Repair Findings ━━━ # Misconfigurations found in this installation, as opposed to defects in Varaverk — those go to # ai_bugs. A finding is open until the configuration is right, and closes itself when the probe @@ -1771,6 +1785,10 @@ # before and here is what fixed it" is worth more than the disk. Open findings are never pruned: # an unresolved problem does not stop mattering because it is old. AI_FINDING_RETAIN_DAYS=90 +# Seconds a single probe may take. Nothing is written to conf that has not answered a probe, so +# this is the budget for proving a candidate — kept short because a sweep may try several, and +# an address worth switching to answers quickly or is not worth switching to. + AI_PROBE_TIMEOUT=4 # ━━━ AI Conf Write Access ━━━ # Separate switch from AI_ENABLED, off by default, and an explicit key whitelist. Never paths, diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index 061588d..2bc1952 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -111,6 +111,7 @@ function vv_ai_conf_is_toggle(string $key): bool { // May the sweep write this without being asked? Two conditions, both required: a probe actually // answered on the proposed value, and the key is not a toggle. function vv_ai_finding_may_autofix(array $f): bool { + if (!vv_ai_repair_autofix_enabled()) return false; if (empty($f['proven'])) return false; if (($f['proposed'] ?? null) === null) return false; if (vv_ai_conf_is_toggle((string)($f['conf_key'] ?? ''))) return false; @@ -143,6 +144,27 @@ function vv_ai_finding_severity(array $f): string { }; } +// ── Gates ──────────────────────────────────────────────────────────────────────────────────── +// Two switches, because detecting and repairing are separate things to trust. +// +// AI_REPAIR_ENABLED alone gives a system that reads logs, files findings and offers fixes, and +// writes nothing. That is the state this should live in first — long enough to read what it +// found and disagree with some of it. A subsystem that starts by editing conf has to be believed +// before there is any evidence for believing it. +// +// AI_REPAIR_AUTOFIX_ENABLED is what lets a proven value be written without being asked, and it +// is meaningless on its own: nothing to write if nothing is looking. Both must be true, in the +// same layered way AI_ENABLED is necessary but never sufficient. +function vv_ai_repair_enabled(): bool { + if (!vv_ai_config()['enabled']) return false; + return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_ENABLED'] ?? 'false'))) === 'true'; +} + +function vv_ai_repair_autofix_enabled(): bool { + if (!vv_ai_repair_enabled()) return false; + return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_AUTOFIX_ENABLED'] ?? 'false'))) === 'true'; +} + function vv_ai_findings_dir(): string { $d = AI_DATA_DIR . '/ai_findings'; if (!is_dir($d)) @mkdir($d, 0755, true); @@ -502,3 +524,153 @@ function vv_ai_triage_log(array $lines, string $sourceLog = ''): array { } return array_values($found); } + +// ── Proving a candidate ────────────────────────────────────────────────────────────────────── +// The guard the whole unattended path rests on: nothing is written that has not answered. +// +// A model can be confident that a port should be 8686. A probe can report that 8686 answered. +// Only the second is a fact, and only facts get written to conf without being asked. Everything +// a probe cannot settle becomes a conversation instead — which is not a lesser outcome, it is +// the honest one for a value that cannot be derived from this machine. +// +// Deliberately narrow. These check reachability and identity, never correctness of behaviour: +// that Lidarr answers on 8686 does not prove 8686 is the port you meant, only that something is +// listening there and calling itself Lidarr. Proving intent is not a probe's job. + +function vv_ai_probe_timeout(): int { + return max(1, (int)(vv_conf_vars()['AI_PROBE_TIMEOUT'] ?? 4)); +} + +// Does this URL answer at all? Any HTTP status counts, including 401 — a refusal is proof that +// something is listening and speaking HTTP, which is exactly what an address probe is asking. +// Distinguishing "wrong address" from "wrong credential" is the point of having both kinds. +function vv_ai_probe_url(string $url): array { + if (!preg_match('#^https?://[^\s/$.?\#][^\s]*$#i', $url)) { + return ['ok' => false, 'reason' => 'not a url']; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_NOBODY => true, + CURLOPT_TIMEOUT => vv_ai_probe_timeout(), + CURLOPT_CONNECTTIMEOUT => vv_ai_probe_timeout(), + CURLOPT_SSL_VERIFYPEER => false, // these are LAN and Tailscale endpoints, often self-signed + CURLOPT_SSL_VERIFYHOST => false, + ]); + curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $err = curl_error($ch); + curl_close($ch); + + return $code > 0 + ? ['ok' => true, 'code' => $code] + : ['ok' => false, 'reason' => $err !== '' ? $err : 'no response']; +} + +// Every container on this host, by exact name. The membership test for unknown_target findings, +// and one docker call rather than one per candidate. +function vv_ai_container_names(): array { + static $names = null; + if ($names !== null) return $names; + + $out = []; + @exec('timeout ' . vv_ai_probe_timeout() . " docker ps -a --format '{{.Names}}' 2>/dev/null", $out, $rc); + return $names = ($rc === 0) ? array_values(array_filter(array_map('trim', $out))) : []; +} + +// Candidate corrections for a URL whose host or port stopped answering. +// +// Only two transformations, both conservative: the same host on a port that some other conf key +// already uses, and the same port on a host some other conf key already names. Both draw +// exclusively from values already present in this installation's conf — nothing is invented, and +// a scan of the port range is deliberately not attempted. Finding *a* listening port is not the +// same as finding the right service, and a probe that accepts any answer would happily point +// Lidarr at Sonarr. +function vv_ai_url_candidates(string $observed): array { + $parts = @parse_url($observed); + if (!is_array($parts) || empty($parts['host'])) return []; + + $hosts = $ports = []; + foreach (vv_conf_vars() as $k => $v) { + $v = trim((string)$v); + if (!preg_match('#^https?://#i', $v)) continue; + $p = @parse_url($v); + if (!is_array($p) || empty($p['host'])) continue; + $hosts[$p['host']] = true; + if (!empty($p['port'])) $ports[(int)$p['port']] = true; + } + + $scheme = $parts['scheme'] ?? 'http'; + $path = $parts['path'] ?? ''; + $out = []; + + foreach (array_keys($ports) as $port) { + $c = $scheme . '://' . $parts['host'] . ':' . $port . $path; + if ($c !== $observed) $out[$c] = true; + } + foreach (array_keys($hosts) as $host) { + $port = !empty($parts['port']) ? ':' . $parts['port'] : ''; + $c = $scheme . '://' . $host . $port . $path; + if ($c !== $observed) $out[$c] = true; + } + return array_keys($out); +} + +// Try to prove a correction for one finding. Returns the finding with 'proposed' and 'proven' +// filled in, or unchanged when nothing could be proven — which is the common case and not a +// failure. +// +// auth_rejected and missing_value are never proven here on purpose. A credential cannot be +// derived from this host by definition: if it could be read from somewhere, it would not be a +// credential. Those go straight to the operator. +function vv_ai_probe_finding(array $f): array { + $kind = (string)($f['kind'] ?? ''); + + if ($kind === 'unknown_target') { + $observed = (string)($f['observed'] ?? ''); + // Exact membership only. A container name is a literal, and "close to an existing name" + // is how a repair renames the wrong thing. + if (in_array($observed, vv_ai_container_names(), true)) { + return $f; // it exists after all — nothing to correct + } + return $f + ['state' => 'needs_operator']; + } + + if ($kind !== 'unreachable') { + // Nothing on this machine can supply a credential, so there is nothing to prove. + $f['state'] = 'needs_operator'; + return $f; + } + + $observed = (string)($f['observed'] ?? ''); + + // If the observed address answers now, the fault has cleared on its own — a host that was + // rebooting, most often. Recording a proposal here would repair something already working. + if (vv_ai_probe_url($observed)['ok']) { + $f['state'] = 'resolved'; + $f['note'] = 'Answered when probed — the address was reachable again by the time this ran.'; + return $f; + } + + $answered = []; + foreach (vv_ai_url_candidates($observed) as $candidate) { + if (vv_ai_probe_url($candidate)['ok']) $answered[] = $candidate; + } + + // Exactly one, or none. Two addresses answering means the probe cannot say which is the + // right one, and picking either is the guess this exists to prevent. + if (count($answered) === 1) { + $f['proposed'] = $answered[0]; + $f['proven'] = true; + $f['note'] = 'Probed ' . $answered[0] . ' and it answered; ' . $observed . ' did not.'; + return $f; + } + + $f['proven'] = false; + $f['state'] = 'needs_operator'; + $f['note'] = $answered + ? 'Several addresses answered (' . implode(', ', $answered) . '), so none was written.' + : 'Nothing answered at ' . $observed . ', and no address in the conf answered either.'; + return $f; +}