Send a bug report to one place, on purpose, after reading it

The page built its own markdown and nothing could send it; local and upstream
are different people, so neither falls back to the other.
This commit is contained in:
Gmer4Lfe
2026-08-13 16:32:36 -04:00
parent 2c1fd4c8bf
commit 2ccc93bb14
6 changed files with 261 additions and 39 deletions
+11
View File
@@ -124,6 +124,17 @@
# Create in Gitea: Settings → Applications → Generate Token → scope: write:user # Create in Gitea: Settings → Applications → Generate Token → scope: write:user
HOSTN_GITEA_API_TOKEN="" 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 ━━━ # ━━━ Notifications ━━━
# Discord webhook — leave blank to disable. # Discord webhook — leave blank to disable.
HOSTN_DISCORD_WEBHOOK="" HOSTN_DISCORD_WEBHOOK=""
+16
View File
@@ -1922,6 +1922,22 @@
# anything that in practice means "mine". Matched as literal text, not as patterns. # anything that in practice means "mine". Matched as literal text, not as patterns.
AI_CHAT_MY_SYSTEM_PHRASES="" 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 ━━━ # ━━━ AI Conf Write Access ━━━
# Separate switch from AI_ENABLED, off by default, and an explicit key whitelist. Never paths, # 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 # never credentials, never a container name. An empty whitelist means no writes regardless of
+46
View File
@@ -382,6 +382,52 @@ if ($action === 'bug_close') {
exit; 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 ───────────────────────────────────────────────── // ── findings / finding_action ─────────────────────────────────────────────────
// What the repair sweep found, and the operator's answer to it. include/ai_repair.php is pulled // 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 // in here rather than at the top of the file: it is the largest include in the plugin and poll
+67
View File
@@ -1372,6 +1372,73 @@ function vv_ai_bug_report(array $b): string {
return $out; 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 // 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 "?". // anything that is not Unraid there is simply no file and the report says "?".
function vv_ai_bug_unraid_version(): string { function vv_ai_bug_unraid_version(): string {
+98 -39
View File
@@ -319,6 +319,20 @@ if (is_dir('/var/log/varaverk')) {
</div> </div>
<div id="vv-ai-bugs"></div> <div id="vv-ai-bugs"></div>
<!-- Manual-copy fallback for plain-http WebGUIs, where the clipboard API is unavailable. --> <!-- Manual-copy fallback for plain-http WebGUIs, where the clipboard API is unavailable. -->
<!-- Review, then send. The box is readonly because the text shown here is the text that
goes — the server re-renders from the store rather than accepting a body from the page,
so an editable box would only create a gap between what was approved and what was sent.
Sending is a separate press from building, and nothing leaves on its own. -->
<div id="vv-ai-bug-send" style="display:none;margin-top:8px;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button class="vv-ai-btn" id="vv-ai-bug-local" type="button"
style="display:none" onclick="vvAiBugSendLocal(this)">Send to Gitea</button>
<button class="vv-ai-btn" id="vv-ai-bug-github" type="button"
onclick="vvAiBugGithub()">Open a GitHub issue</button>
<button class="vv-ai-btn ghost" type="button" onclick="vvAiBugCopy()">Copy</button>
</div>
<div class="vv-ai-set-d" id="vv-ai-bug-dest" style="margin-top:6px;"></div>
</div>
<textarea id="vv-ai-bug-copybox" class="vv-ai-input" rows="8" readonly spellcheck="false" <textarea id="vv-ai-bug-copybox" class="vv-ai-input" rows="8" readonly spellcheck="false"
style="display:none;margin-top:8px" onclick="this.select()"></textarea> style="display:none;margin-top:8px" onclick="this.select()"></textarea>
</div> </div>
@@ -616,7 +630,8 @@ vv_ai_chat_markup('vv-ai', [
function renderBugs(bugs) { function renderBugs(bugs) {
const wrap = $('vv-ai-bugs-wrap'); 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; } if (!bugs.length) { wrap.style.display = 'none'; return; }
wrap.style.display = ''; wrap.style.display = '';
$('vv-ai-bugs-sum').innerHTML = $('vv-ai-bugs-sum').innerHTML =
@@ -631,7 +646,7 @@ vv_ai_chat_markup('vv-ai', [
<span class="vv-ai-bug-m">${esc(b.id)}${esc(seen)} · ${esc(when)}${ <span class="vv-ai-bug-m">${esc(b.id)}${esc(seen)} · ${esc(when)}${
b.context && b.context.verified === null b.context && b.context.verified === null
? ' · <span class="vv-ai-warn">evidence unverified</span>' : ''}</span> ? ' · <span class="vv-ai-warn">evidence unverified</span>' : ''}</span>
<button class="vv-ai-btn ghost" onclick="vvAiBugCopy('${esc(b.id)}')">Copy issue</button> <button class="vv-ai-btn" onclick="vvAiBugReport('${esc(b.id)}')">Report…</button>
<button class="vv-ai-btn ghost" onclick="vvAiBugClose('${esc(b.id)}')">Dismiss</button> <button class="vv-ai-btn ghost" onclick="vvAiBugClose('${esc(b.id)}')">Dismiss</button>
</div> </div>
<div class="vv-ai-bug-s">${esc(b.summary)}</div> <div class="vv-ai-bug-s">${esc(b.summary)}</div>
@@ -642,46 +657,90 @@ vv_ai_chat_markup('vv-ai', [
}).join(''); }).join('');
} }
// Copy out, never transmit. The operator reads the text before it goes anywhere, which is the // The report itself is rendered by the server — see api/ai.php?action=bug_report. This page
// whole reason this is a button and not a webhook — evidence is quoted log lines, and Varaverk // used to build its own markdown, which meant two formats for the same thing and no way to
// logs carry share names, container names and paths. Redaction by inspection beats redaction // tell which one a given issue had been filed with.
// by rule, and it cannot be unpublished later. //
let vvBugs = []; // 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) { fetch(API + '?action=bug_report&id=' + encodeURIComponent(id))
const d = t => t ? new Date(t * 1000).toISOString().slice(0, 10) : '—'; .then(r => r.json())
return `### ${b.component} — ${b.summary}\n\n` .then(d => {
+ `| | |\n|---|---|\n` if (!d.ok) throw new Error(d.error || 'could not build the report');
+ `| Component | \`${b.component}\` |\n` box.value = d.markdown;
+ `| Varaverk | \`${b.commit || 'unknown'}\` |\n` bar.dataset.id = id;
+ `| Host slot | ${b.host || '—'} |\n` bar.dataset.url = d.github;
+ `| Seen | ${b.seen}× · ${d(b.first)} → ${d(b.last)} |\n\n` bar.style.display = '';
+ `**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`;
}
window.vvAiBugCopy = function (id) { const t = d.targets || {};
const b = vvBugs.find(x => x.id === id); const local = $('vv-ai-bug-local'), gh = $('vv-ai-bug-github');
if (!b) return; // Only offered where it can actually work. A button that reports "local reporting is
const text = vvAiBugMarkdown(b); // switched off" after being pressed is a button that should not have been drawn.
const done = ok => { local.style.display = (t.local_enabled && t.local_configured) ? '' : 'none';
const btn = event && event.target; local.textContent = 'Send to ' + (t.local_repo || 'Gitea');
if (btn) { btn.textContent = ok ? 'Copied' : 'Select below'; setTimeout(() => btn.textContent = 'Copy issue', 1800); } // GitHub is the path when local is off — and stays available when it is on, because
if (!ok) { // "my own backlog" and "tell the maintainer" are different intentions, not a fallback.
// The clipboard API needs a secure context and the WebGUI is often plain http on the gh.style.display = '';
// LAN. Falling back to a selectable box means the button always does something useful $('vv-ai-bug-dest').textContent = t.local_enabled
// rather than failing silently on exactly the setups this is built for. ? (t.local_configured
const box = document.getElementById('vv-ai-bug-copybox'); ? 'Local reporting is on — this stays on your Gitea and does not reach the maintainer.'
box.style.display = ''; box.value = text; box.focus(); box.select(); : '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();
if (navigator.clipboard && window.isSecureContext) { })
navigator.clipboard.writeText(text).then(() => done(true)).catch(() => done(false)); .catch(e => { box.value = 'Could not build the report: ' + (e.message || e); });
} else { };
done(false);
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 = `<span class="vv-ai-bad">${esc(d.error || 'failed')}</span>`; return; }
// The issue URL is the receipt. Without it "sent" is just a word the page chose.
out.innerHTML = d.url
? `<span class="vv-ai-ok">filed:</span> <a href="${esc(d.url)}" target="_blank"
rel="noopener noreferrer" style="color:#7d9be8">${esc(d.url)}</a>`
: '<span class="vv-ai-ok">filed</span>';
})
.catch(e => { btn.disabled = false; out.innerHTML = `<span class="vv-ai-bad">${esc(String(e))}</span>`; });
};
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 =
'<span class="vv-ai-warn">Too long for a prefilled URL — copy the text above into a new '
+ 'issue instead.</span>';
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) { window.vvAiBugClose = function (id) {
+23
View File
@@ -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. | | `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 ## Certificate Monitor
Reachable from: 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_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 | | `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 ## Certificate Monitor
Reachable from: Reachable from: