diff --git a/Deployment/host.conf.template b/Deployment/host.conf.template index d9fa539..737cf7f 100644 --- a/Deployment/host.conf.template +++ b/Deployment/host.conf.template @@ -124,6 +124,17 @@ # Create in Gitea: Settings → Applications → Generate Token → scope: write:user HOSTN_GITEA_API_TOKEN="" +# ━━━ Bug Reports ━━━ +# Only used when BUG_REPORT_LOCAL_ENABLED=true in master.conf. Reports then go to this Gitea +# instead of GitHub — and stay there, so they do not reach the Varaverk maintainer. +# +# Reached locally or over Tailscale, so the token never crosses the public proxy and no Authelia +# bypass is needed. It is a credential and lives here rather than master.conf for that reason; +# it is never shipped, and the settings UI masks it. + HOSTN_BUG_REPORT_URL="" # e.g. http://gitea:3000 or the tailnet name + HOSTN_BUG_REPORT_REPO="" # owner/repo + HOSTN_BUG_REPORT_TOKEN="" # Gitea API token with issue-write on that repo + # ━━━ Notifications ━━━ # Discord webhook — leave blank to disable. HOSTN_DISCORD_WEBHOOK="" diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 4eece09..5cf36aa 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1922,6 +1922,22 @@ # anything that in practice means "mine". Matched as literal text, not as patterns. AI_CHAT_MY_SYSTEM_PHRASES="" +# ━━━ Bug Reports ━━━ +# A bug the assistant files is written here and goes nowhere until the owner sends it. Nothing is +# ever transmitted automatically — the report is shown in full, read-only, and sending is a +# second, separate press. +# +# Two destinations, and they are not a fallback chain. Fetching code from several mirrors is +# harmless because they all serve the same thing; sending a report is not, because the +# destinations are different people. Local ships OFF so an install that has configured nothing +# reports upstream rather than silently into a tracker nobody reads. +# +# LOCAL ON — reports go to your own Gitea (see HOSTN_BUG_REPORT_* in host*.conf) and stay there. +# They do NOT reach the Varaverk maintainer. Turn it on if you want your own backlog. +# LOCAL OFF — reports open a prefilled GitHub issue you submit under your own account. + BUG_REPORT_LOCAL_ENABLED=false + BUG_REPORT_GITHUB_REPO="FailedProxy/Varaverk" + # ━━━ 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 diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index c079998..e13ec88 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -382,6 +382,52 @@ if ($action === 'bug_close') { exit; } +// The report, rendered server-side. Read-only by design: what the operator reviews is byte for +// byte what gets sent, so approving one text and transmitting another is not possible. It is also +// the only renderer — the page used to build its own markdown, which is two formats to keep in +// step and one of them always losing. +if ($action === 'bug_report') { + $id = trim($_GET['id'] ?? ''); + $bug = null; + foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; } + if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; } + + $t = vv_ai_bug_targets(); + $title = '[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''); + echo json_encode([ + 'ok' => true, + 'title' => $title, + 'markdown' => vv_ai_bug_report($bug), + 'targets' => $t, + // Built here because the repo name lives here. Length is the caller's problem to notice: + // GitHub truncates a very long query rather than refusing it, which would silently send a + // half report — so the page checks and falls back to the copy box. + 'github' => 'https://github.com/' . $t['github_repo'] . '/issues/new?title=' + . rawurlencode($title) . '&body=' . rawurlencode(vv_ai_bug_report($bug)), + ]); + exit; +} + +// Sends to the operator's own Gitea, and only there. Never falls back to GitHub on failure: the +// two destinations are different people, and a silent substitution is how a report meant for a +// private backlog ends up public. +if ($action === 'bug_send_local') { + if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } + $id = trim($_POST['id'] ?? ''); + $bug = null; + foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; } + if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; } + + // Re-rendered from the store rather than taken from the request. The browser showed this text + // read-only; accepting a body from the page would make that guarantee decorative. + $r = vv_ai_bug_send_local('[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''), + vv_ai_bug_report($bug)); + vv_ai_log(sprintf('bug_send_local id=%s %s', $id, + $r['ok'] ? 'ok ' . ($r['url'] ?? '') : 'failed: ' . ($r['error'] ?? '?'))); + echo json_encode($r); + exit; +} + // ── findings / finding_action ───────────────────────────────────────────────── // What the repair sweep found, and the operator's answer to it. include/ai_repair.php is pulled // in here rather than at the top of the file: it is the largest include in the plugin and poll diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index c6e6b80..5f24aa2 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -1372,6 +1372,73 @@ function vv_ai_bug_report(array $b): string { return $out; } +// Where a report can go from this install, resolved once so the page and the sender agree. +// +// Local is opt-in and off by default, because a default that files into the operator's own +// tracker means every report from every other install lands somewhere the maintainer never +// looks — a fallback chain is right for fetching code and wrong for sending a report. +function vv_ai_bug_targets(): array { + $v = vv_conf_vars(); + $slot = strtoupper(vv_detect_host()); + $on = strtolower(trim($v['BUG_REPORT_LOCAL_ENABLED'] ?? 'false')) === 'true'; + + $url = trim((string) ($v[$slot . '_BUG_REPORT_URL'] ?? '')); + $repo = trim((string) ($v[$slot . '_BUG_REPORT_REPO'] ?? '')); + $token = trim((string) ($v[$slot . '_BUG_REPORT_TOKEN'] ?? '')); + + return [ + // Configured is not the same as enabled: the switch is off but the details are filled in + // is a state worth showing, because it is what "why did this go to GitHub" looks like. + 'local_enabled' => $on, + 'local_configured' => $url !== '' && $repo !== '' && $token !== '', + 'local_url' => $url, + 'local_repo' => $repo, + 'github_repo' => trim((string) ($v['BUG_REPORT_GITHUB_REPO'] ?? 'FailedProxy/Varaverk')), + ]; +} + +// Files the report as an issue on the operator's own Gitea. Only ever reached when the switch is +// on and the details are present — never as a fallback from a failed GitHub send, because the two +// go to different people and quietly substituting one for the other is the whole failure mode +// this design exists to avoid. +function vv_ai_bug_send_local(string $title, string $body): array { + $t = vv_ai_bug_targets(); + if (!$t['local_enabled']) return ['ok' => false, 'error' => 'local reporting is switched off']; + if (!$t['local_configured']) return ['ok' => false, 'error' => 'local reporting is on but URL, repo or token is blank']; + if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'curl is unavailable']; + + $v = vv_conf_vars(); + $slot = strtoupper(vv_detect_host()); + $token = trim((string) ($v[$slot . '_BUG_REPORT_TOKEN'] ?? '')); + $api = rtrim($t['local_url'], '/') . '/api/v1/repos/' . trim($t['local_repo'], '/') . '/issues'; + + $ch = curl_init($api); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', + 'Authorization: token ' . $token], + CURLOPT_POSTFIELDS => json_encode(['title' => $title, 'body' => $body]), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_FOLLOWLOCATION => false, + ]); + $resp = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($resp === false) return ['ok' => false, 'error' => 'could not reach Gitea: ' . $err]; + if ($code === 401 || $code === 403) return ['ok' => false, 'error' => 'Gitea refused the token (HTTP ' . $code . ')']; + if ($code === 404) return ['ok' => false, 'error' => 'Gitea has no such repo (HTTP 404) — check the owner/repo']; + if ($code < 200 || $code >= 300) return ['ok' => false, 'error' => 'Gitea returned HTTP ' . $code]; + + $d = json_decode((string) $resp, true); + // The URL back is the whole point of sending server-side rather than opening a form: it is + // proof the issue exists, and somewhere to go and look at it. + return ['ok' => true, 'url' => $d['html_url'] ?? '', 'number' => $d['number'] ?? null]; +} + // Best effort, and blank rather than wrong. /etc/unraid-version is a shell assignment; on // anything that is not Unraid there is simply no file and the report says "?". function vv_ai_bug_unraid_version(): string { diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index c1c5b39..942a0e4 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -319,6 +319,20 @@ if (is_dir('/var/log/varaverk')) {
+ + @@ -616,7 +630,8 @@ vv_ai_chat_markup('vv-ai', [ function renderBugs(bugs) { const wrap = $('vv-ai-bugs-wrap'); - vvBugs = bugs; + // The list used to be cached here for the page's own markdown builder. The server renders the + // report now, fetched by id, so there is nothing left to keep. if (!bugs.length) { wrap.style.display = 'none'; return; } wrap.style.display = ''; $('vv-ai-bugs-sum').innerHTML = @@ -631,7 +646,7 @@ vv_ai_chat_markup('vv-ai', [ ${esc(b.id)}${esc(seen)} · ${esc(when)}${ b.context && b.context.verified === null ? ' · evidence unverified' : ''} - +