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' : ''} - +
${esc(b.summary)}
@@ -642,46 +657,90 @@ vv_ai_chat_markup('vv-ai', [ }).join(''); } - // Copy out, never transmit. The operator reads the text before it goes anywhere, which is the - // whole reason this is a button and not a webhook — evidence is quoted log lines, and Varaverk - // logs carry share names, container names and paths. Redaction by inspection beats redaction - // by rule, and it cannot be unpublished later. - let vvBugs = []; + // The report itself is rendered by the server — see api/ai.php?action=bug_report. This page + // used to build its own markdown, which meant two formats for the same thing and no way to + // tell which one a given issue had been filed with. + // + // Read-only on purpose. What is reviewed is byte for byte what is sent, so there is no gap + // between the text approved and the text transmitted — and the server re-renders from the + // store when sending rather than trusting anything the page hands back. + window.vvAiBugReport = function (id) { + const box = document.getElementById('vv-ai-bug-copybox'); + const bar = document.getElementById('vv-ai-bug-send'); + box.value = 'Loading…'; box.style.display = ''; bar.style.display = 'none'; - function vvAiBugMarkdown(b) { - const d = t => t ? new Date(t * 1000).toISOString().slice(0, 10) : '—'; - return `### ${b.component} — ${b.summary}\n\n` - + `| | |\n|---|---|\n` - + `| Component | \`${b.component}\` |\n` - + `| Varaverk | \`${b.commit || 'unknown'}\` |\n` - + `| Host slot | ${b.host || '—'} |\n` - + `| Seen | ${b.seen}× · ${d(b.first)} → ${d(b.last)} |\n\n` - + `**Evidence**\n\n\`\`\`\n${b.evidence}\n\`\`\`\n\n` - + (b.context && b.context.asked ? `**Asked while diagnosing:** ${b.context.asked}\n\n` : '') - + (b.context && b.context.log ? `**Log:** \`${b.context.log}\`\n\n` : '') - + `_Filed automatically by the Varaverk assistant. Report ${b.id}._\n`; - } + fetch(API + '?action=bug_report&id=' + encodeURIComponent(id)) + .then(r => r.json()) + .then(d => { + if (!d.ok) throw new Error(d.error || 'could not build the report'); + box.value = d.markdown; + bar.dataset.id = id; + bar.dataset.url = d.github; + bar.style.display = ''; - window.vvAiBugCopy = function (id) { - const b = vvBugs.find(x => x.id === id); - if (!b) return; - const text = vvAiBugMarkdown(b); - const done = ok => { - const btn = event && event.target; - if (btn) { btn.textContent = ok ? 'Copied' : 'Select below'; setTimeout(() => btn.textContent = 'Copy issue', 1800); } - if (!ok) { - // The clipboard API needs a secure context and the WebGUI is often plain http on the - // LAN. Falling back to a selectable box means the button always does something useful - // rather than failing silently on exactly the setups this is built for. - const box = document.getElementById('vv-ai-bug-copybox'); - box.style.display = ''; box.value = text; box.focus(); box.select(); - } - }; - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(text).then(() => done(true)).catch(() => done(false)); - } else { - done(false); + const t = d.targets || {}; + const local = $('vv-ai-bug-local'), gh = $('vv-ai-bug-github'); + // Only offered where it can actually work. A button that reports "local reporting is + // switched off" after being pressed is a button that should not have been drawn. + local.style.display = (t.local_enabled && t.local_configured) ? '' : 'none'; + local.textContent = 'Send to ' + (t.local_repo || 'Gitea'); + // GitHub is the path when local is off — and stays available when it is on, because + // "my own backlog" and "tell the maintainer" are different intentions, not a fallback. + gh.style.display = ''; + $('vv-ai-bug-dest').textContent = t.local_enabled + ? (t.local_configured + ? 'Local reporting is on — this stays on your Gitea and does not reach the maintainer.' + : 'Local reporting is on but URL, repo or token is blank, so only GitHub is available.') + : 'Opens a prefilled issue you submit under your own GitHub account.'; + box.focus(); box.select(); + }) + .catch(e => { box.value = 'Could not build the report: ' + (e.message || e); }); + }; + + window.vvAiBugSendLocal = function (btn) { + const bar = document.getElementById('vv-ai-bug-send'); + const out = $('vv-ai-bug-dest'); + btn.disabled = true; + out.textContent = 'sending…'; + // URLSearchParams, not FormData — a multipart POST to this endpoint hangs with no status. + fetch(API, { method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: new URLSearchParams({ action: 'bug_send_local', id: bar.dataset.id }) }) + .then(r => r.json()) + .then(d => { + btn.disabled = false; + if (!d.ok) { out.innerHTML = `${esc(d.error || 'failed')}`; return; } + // The issue URL is the receipt. Without it "sent" is just a word the page chose. + out.innerHTML = d.url + ? `filed: ${esc(d.url)}` + : 'filed'; + }) + .catch(e => { btn.disabled = false; out.innerHTML = `${esc(String(e))}`; }); + }; + + window.vvAiBugGithub = function () { + const bar = document.getElementById('vv-ai-bug-send'); + const url = bar.dataset.url || ''; + // GitHub silently truncates an over-long query rather than refusing it, which would file half + // a report and look like it worked. Past that length the copy box is the honest path. + if (url.length > 7500) { + $('vv-ai-bug-dest').innerHTML = + 'Too long for a prefilled URL — copy the text above into a new ' + + 'issue instead.'; + return; } + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + // Kept because the clipboard API needs a secure context and the WebGUI is often plain http on + // the LAN — on exactly the setups this is built for, the box is the path that works. + window.vvAiBugCopy = function (id) { + const box = document.getElementById('vv-ai-bug-copybox'); + if (!box.value || box.style.display === 'none') { vvAiBugReport(id); return; } + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(box.value).catch(() => { box.focus(); box.select(); }); + } else { box.focus(); box.select(); } }; window.vvAiBugClose = function (id) { diff --git a/Plugin/unraid/pages/readme/ui-map.md b/Plugin/unraid/pages/readme/ui-map.md index e43f86d..a22cc1c 100644 --- a/Plugin/unraid/pages/readme/ui-map.md +++ b/Plugin/unraid/pages/readme/ui-map.md @@ -66,6 +66,18 @@ Saved into `host1.conf`, which does not need to be opened by hand. |---|---|---|---| | `HOST1_BACKUP_VERIFY_SHARES` | a list, one entry per line | in this section | Leave empty to use HOST1_DAILY_SYNC_SHARES automatically. | +## Bug Reports + +Route: Settings tab → All settings → *Bug Reports* + +Saved into `host1.conf`, which does not need to be opened by hand. + +| Setting | Control | Where | What it does | +|---|---|---|---| +| `HOST1_BUG_REPORT_URL` | a text box | in this section | e.g. http://gitea:3000 or the tailnet name | +| `HOST1_BUG_REPORT_REPO` | a text box | in this section | owner/repo | +| `HOST1_BUG_REPORT_TOKEN` | a masked box with a **Show** button | in this section | Gitea API token with issue-write on that repo | + ## Certificate Monitor Reachable from: @@ -1143,6 +1155,17 @@ Saved into `master.conf`, which does not need to be opened by hand. | `ARR_RECOVERY_STATS` | a text box | in this section | blocklist + re-search stats | | `ARR_RECOVERY_FAILURE_COUNTS` | a text box | in this section | per-item chronic-failure tracking | +## Bug Reports + +Route: Settings tab → All settings → *Bug Reports* + +Saved into `master.conf`, which does not need to be opened by hand. + +| Setting | Control | Where | What it does | +|---|---|---|---| +| `BUG_REPORT_LOCAL_ENABLED` | a switch | in this section | LOCAL ON — reports go to your own Gitea (HOST1_BUG_REPORT_* in host1.conf) and stay there. They do NOT reach the Varaverk maintainer. LOCAL OFF — reports open a prefilled GitHub issue you submit under your own account. Nothing is ever transmitted automatically: the report is shown read-only and sending is a separate press. | +| `BUG_REPORT_GITHUB_REPO` | a text box | in this section | — | + ## Certificate Monitor Reachable from: