From 310c7e6e5195271efc8200aebb3f32feef687c2c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:14:39 +0100 Subject: [PATCH 1/6] Document safe removal of mistakenly created Patrol rules Explain the missing v6.4.5 undo control and the existing scoped API. Verify exact selection, confirmation, browser session and CSRF, failure handling and readback without copying credentials or deleting unrelated rules. Contract-Neutral: Documentation and regression-proof coverage of existing Patrol suppression APIs only; no runtime, route, authority or subsystem contract changes. Change-source: pulse-maintainer --- docs/AI.md | 85 +++++++++- .../browser-tests/patrol-rule-help.cjs | 160 ++++++++++++++++++ frontend-modern/public/docs/AI.md | 85 +++++++++- .../api/patrol_suppression_management_test.go | 107 ++++++++++++ scripts/tests/test_patrol_rule_help.py | 158 +++++++++++++++++ 5 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 frontend-modern/browser-tests/patrol-rule-help.cjs create mode 100644 internal/api/patrol_suppression_management_test.go create mode 100644 scripts/tests/test_patrol_rule_help.py diff --git a/docs/AI.md b/docs/AI.md index 520ac9525a..f23b965ece 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -230,10 +230,93 @@ Findings can be managed via the UI or API: - **Get help**: Chat with AI to troubleshoot the issue - **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces) -- **Dismiss**: Mark as expected behavior (creates suppression rule) +- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal +- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category Dismissed and resolved findings persist across Pulse restarts. +#### Remove a rule created by mistake + +In v6.4.5, **Create rule** has no corresponding rule-list or delete control in +the UI. **Reopen** undoes an individual finding's dismissal, not a separately +created rule. The existing API can list rules and delete one by its exact ID. +Deleting a created rule allows future matching findings to appear again; it +does not resolve a problem or automatically reopen previously dismissed +findings. It leaves the other rules and finding history intact. + +For a rule made with **Create rule**, sign in normally to Pulse in an +authorised administrator's browser session, select the correct organisation, +and open that Pulse tab's developer console. Review the following snippet +before running it. It lists only manually created rules, asks for one exact +ID, and confirms its resource, category and reason before deleting it. Cancel +either prompt to leave the rules unchanged. Keep the list private: resource +names and reasons can describe your infrastructure. + +No password, API token or session cookie needs to be copied or pasted. The +browser supplies its existing session; the snippet reads only the normal +CSRF and organisation cookies locally. Do not switch organisation while it +runs, use it on another website, disable authentication/CSRF protection, or +edit Pulse's data files. A token-only login or an expired session may not +support this workaround: stop on an authentication or missing-CSRF error +instead of inserting credentials into the snippet. + +```javascript +(async () => { + const cookie = (name) => { + const row = document.cookie.split(';').find((part) => part.trim().startsWith(`${name}=`)); + return row ? decodeURIComponent(row.trim().slice(name.length + 1)) : ''; + }; + const org = cookie('pulse_org_id') || 'default'; + const headers = { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-Pulse-Org-ID': org, + }; + const readRules = async () => { + if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.'); + const response = await fetch('/api/ai/patrol/suppressions', { + credentials: 'same-origin', redirect: 'error', cache: 'no-store', headers, + }); + if (!response.ok) throw new Error(`Rule list failed (HTTP ${response.status}); stop.`); + const rules = await response.json(); + if (!Array.isArray(rules)) throw new Error('Unexpected rule list; stop.'); + return rules.filter((rule) => rule && rule.created_from === 'manual'); + }; + const rules = await readRules(); + console.table(rules.map((rule) => ({ + id: rule.id, resource: rule.resource_name || rule.resource_id || 'Any resource', + category: rule.category || 'Any category', reason: rule.description, + }))); + const id = prompt('Enter one exact rule ID from this list, or Cancel to keep all rules.'); + if (id === null || !id.trim()) return; + const matches = rules.filter((rule) => typeof rule.id === 'string' && rule.id === id.trim()); + if (matches.length !== 1) throw new Error('ID must match exactly one listed manual rule; stop.'); + const rule = matches[0]; + if (!confirm(`Delete only ${rule.id}?\nResource: ${rule.resource_name || rule.resource_id || 'Any resource'}\nCategory: ${rule.category || 'Any category'}\nReason: ${rule.description}\nFuture matching findings may appear again.`)) return; + if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.'); + const csrf = cookie('pulse_csrf'); + if (!csrf) throw new Error('No browser CSRF token; stop and sign in normally, without pasting tokens.'); + const response = await fetch(`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`, { + method: 'DELETE', credentials: 'same-origin', redirect: 'error', + headers: { ...headers, 'X-CSRF-Token': csrf }, + }); + if (!response.ok) throw new Error(`Deletion not confirmed (HTTP ${response.status}); stop.`); + if ((await readRules()).some((remaining) => remaining.id === rule.id)) { + throw new Error('Rule is still listed; removal was not verified.'); + } + console.info('Selected rule is no longer listed. Other rules were not deleted.'); +})().catch((error) => console.error(error.message)); +``` + +A failed or lost response is not confirmation of removal. Do not blindly run +the deletion again: refresh the rule list first (open +`/api/ai/patrol/suppressions` in the same signed-in Pulse browser) and check the +selected ID. If the rule is absent, it was already removed. If you cannot +verify the result, stop and report only the HTTP status or error, not cookies, +tokens or the full rule list. Entries whose `created_from` is `finding` or +`dismissed` are individual finding decisions and are deliberately excluded +from this snippet. + Every active finding shown or returned to a Patrol run must receive an explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an all-clear signal. `present` refreshes current evidence, `resolved` remains diff --git a/frontend-modern/browser-tests/patrol-rule-help.cjs b/frontend-modern/browser-tests/patrol-rule-help.cjs new file mode 100644 index 0000000000..6e08008b36 --- /dev/null +++ b/frontend-modern/browser-tests/patrol-rule-help.cjs @@ -0,0 +1,160 @@ +// Run from the Pulse workspace root with pulse-worker-browser. +// This exercises the documented snippet against a synthetic same-origin API, +// not a deployed Pulse installation or the still-missing rule-management UI. +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +const guide = fs.readFileSync('docs/AI.md', 'utf8'); +const section = guide.split('#### Remove a rule created by mistake\n')[1]; +const snippet = section.match(/```javascript\n([\s\S]*?)\n```/)[1]; +const output = 'frontend-modern/browser-tests/artifacts/patrol-rule-help'; +fs.mkdirSync(output, { recursive: true }); +const escape = (text) => text.replaceAll('&', '&').replaceAll('<', '<'); +const selected = { id: 'rule-one', created_from: 'manual', resource_name: 'Test VM', + resource_id: 'vm-1', category: 'backup', description: 'Created by mistake' }; +const other = { ...selected, id: 'rule-two', resource_id: 'vm-2', resource_name: 'Other VM' }; +let scenario; +const server = http.createServer((request, response) => { + if (request.url === '/') { + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.setHeader('Set-Cookie', [ + 'pulse_session=synthetic-session; Path=/; HttpOnly; SameSite=Lax', + 'pulse_org_id=org-a; Path=/; SameSite=Lax', + ...(scenario.noCSRF ? [] : ['pulse_csrf=synthetic-csrf; Path=/; SameSite=Lax']), + ]); + response.end(`

Rule removal help — synthetic browser check

+

Cancel keeps both rules. A confirmed removal targets one exact ID.

+

No removal confirmed

+
${escape(snippet)}
`); + return; + } + assert.ok(request.url.startsWith('/api/ai/patrol/suppressions')); + const hasSession = (request.headers.cookie || '').includes('pulse_session=synthetic-session'); + const expectedOrg = request.headers['x-pulse-org-id'] === 'org-a'; + const validCSRF = request.headers['x-csrf-token'] === 'synthetic-csrf'; + scenario.requests.push({ method: request.method, path: request.url, + hasSession, expectedOrg, validCSRF }); + response.setHeader('Content-Type', 'application/json'); + if (!hasSession || !expectedOrg) { + response.writeHead(403).end('{}'); + return; + } + if (request.method === 'DELETE') { + assert.equal(request.url, '/api/ai/patrol/suppressions/rule-one'); + if (!validCSRF || scenario.rejectDelete) { + response.writeHead(403).end('{}'); + return; + } + scenario.removed = true; + response.end('{"success":true}'); + return; + } + assert.equal(request.method, 'GET'); + assert.equal(request.url, '/api/ai/patrol/suppressions'); + if (scenario.removed && scenario.readbackFailure) { + response.writeHead(503).end('{}'); + return; + } + response.end(JSON.stringify(scenario.removed ? [other] : [selected, other])); +}); + +(async () => { + let browser; + const receipts = []; + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}/`; + for (const [engineName, engine] of [['chromium', chromium], ['webkit', webkit]]) { + browser = await engine.launch(engineName === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const cases = engineName === 'chromium' + ? ['prompt-cancel', 'confirm-cancel', 'success', 'missing-csrf', 'changed-org', + 'delete-rejected', 'readback-failed'] + : ['prompt-cancel', 'success']; + for (const name of cases) { + scenario = { name, requests: [], removed: false, + noCSRF: name === 'missing-csrf', rejectDelete: name === 'delete-rejected', + readbackFailure: name === 'readback-failed' }; + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + const messages = []; + const dialogs = []; + page.on('console', (message) => { + if (['info', 'error'].includes(message.type())) { + messages.push({ kind: message.type(), message: message.text() }); + } + }); + page.on('dialog', async (dialog) => { + dialogs.push({ type: dialog.type(), message: dialog.message() }); + if (dialog.type() === 'prompt') { + if (name === 'prompt-cancel') await dialog.dismiss(); + else await dialog.accept('rule-one'); + } else { + if (name === 'confirm-cancel') await dialog.dismiss(); + else await dialog.accept(); + } + }); + await page.goto(url); + if (name === 'changed-org') { + // Change context at the documented confirmation boundary. + const changedSnippet = snippet.replace( + "const csrf = cookie('pulse_csrf');", + "const csrf = cookie('pulse_csrf');"); + // The browser test changes the cookie when the prompt opens, before + // the exact, unchanged snippet reaches its organisation recheck. + page.once('dialog', async () => { + await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]); + }); + assert.equal(changedSnippet, snippet); + } + await page.evaluate((code) => window.eval(code), snippet); + const deletes = scenario.requests.filter((request) => request.method === 'DELETE'); + const success = messages.some((message) => message.kind === 'info'); + if (name === 'success') { + assert.equal(deletes.length, 1); + assert.ok(deletes[0].validCSRF); + assert.equal(scenario.requests.length, 3); + assert.ok(scenario.removed); + assert.ok(success); + } else { + assert.equal(success, false); + if (['delete-rejected', 'readback-failed'].includes(name)) { + assert.equal(deletes.length, 1); + } else { + assert.equal(deletes.length, 0); + } + } + assert.ok(scenario.requests.every((request) => request.hasSession && request.expectedOrg)); + assert.ok(messages.every((message) => !message.message.includes('synthetic-csrf'))); + assert.ok(dialogs.every((dialog) => !dialog.message.includes('synthetic-csrf'))); + await page.locator('#result').evaluate((element, text) => { element.textContent = text; }, + success ? 'Selected rule absent after readback; other rule retained' : `${name}: no removal confirmed`); + let screenshot; + if (['success', 'confirm-cancel', 'delete-rejected'].includes(name)) { + screenshot = path.join(output, `${engineName}-${name}.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + } + receipts.push({ engine: engineName, browserVersion: browser.version(), name, + requests: scenario.requests, messages, dialogs, removed: scenario.removed, screenshot }); + await context.close(); + } + await browser.close(); + browser = null; + } + const result = { guideSHA256: crypto.createHash('sha256').update(guide).digest('hex'), + playwrightVersion: require('playwright/package.json').version, + evidenceKind: 'synthetic same-origin browser/API workaround; no installed Pulse or rule-management UI', + scenarios: receipts }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify({ passed: receipts.length, guideSHA256: result.guideSHA256, + playwrightVersion: result.playwrightVersion, result: path.join(output, 'result.json') })); + } finally { + if (browser) await browser.close(); + await new Promise((resolve) => server.close(resolve)); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/AI.md b/frontend-modern/public/docs/AI.md index 520ac9525a..f23b965ece 100644 --- a/frontend-modern/public/docs/AI.md +++ b/frontend-modern/public/docs/AI.md @@ -230,10 +230,93 @@ Findings can be managed via the UI or API: - **Get help**: Chat with AI to troubleshoot the issue - **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces) -- **Dismiss**: Mark as expected behavior (creates suppression rule) +- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal +- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category Dismissed and resolved findings persist across Pulse restarts. +#### Remove a rule created by mistake + +In v6.4.5, **Create rule** has no corresponding rule-list or delete control in +the UI. **Reopen** undoes an individual finding's dismissal, not a separately +created rule. The existing API can list rules and delete one by its exact ID. +Deleting a created rule allows future matching findings to appear again; it +does not resolve a problem or automatically reopen previously dismissed +findings. It leaves the other rules and finding history intact. + +For a rule made with **Create rule**, sign in normally to Pulse in an +authorised administrator's browser session, select the correct organisation, +and open that Pulse tab's developer console. Review the following snippet +before running it. It lists only manually created rules, asks for one exact +ID, and confirms its resource, category and reason before deleting it. Cancel +either prompt to leave the rules unchanged. Keep the list private: resource +names and reasons can describe your infrastructure. + +No password, API token or session cookie needs to be copied or pasted. The +browser supplies its existing session; the snippet reads only the normal +CSRF and organisation cookies locally. Do not switch organisation while it +runs, use it on another website, disable authentication/CSRF protection, or +edit Pulse's data files. A token-only login or an expired session may not +support this workaround: stop on an authentication or missing-CSRF error +instead of inserting credentials into the snippet. + +```javascript +(async () => { + const cookie = (name) => { + const row = document.cookie.split(';').find((part) => part.trim().startsWith(`${name}=`)); + return row ? decodeURIComponent(row.trim().slice(name.length + 1)) : ''; + }; + const org = cookie('pulse_org_id') || 'default'; + const headers = { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-Pulse-Org-ID': org, + }; + const readRules = async () => { + if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.'); + const response = await fetch('/api/ai/patrol/suppressions', { + credentials: 'same-origin', redirect: 'error', cache: 'no-store', headers, + }); + if (!response.ok) throw new Error(`Rule list failed (HTTP ${response.status}); stop.`); + const rules = await response.json(); + if (!Array.isArray(rules)) throw new Error('Unexpected rule list; stop.'); + return rules.filter((rule) => rule && rule.created_from === 'manual'); + }; + const rules = await readRules(); + console.table(rules.map((rule) => ({ + id: rule.id, resource: rule.resource_name || rule.resource_id || 'Any resource', + category: rule.category || 'Any category', reason: rule.description, + }))); + const id = prompt('Enter one exact rule ID from this list, or Cancel to keep all rules.'); + if (id === null || !id.trim()) return; + const matches = rules.filter((rule) => typeof rule.id === 'string' && rule.id === id.trim()); + if (matches.length !== 1) throw new Error('ID must match exactly one listed manual rule; stop.'); + const rule = matches[0]; + if (!confirm(`Delete only ${rule.id}?\nResource: ${rule.resource_name || rule.resource_id || 'Any resource'}\nCategory: ${rule.category || 'Any category'}\nReason: ${rule.description}\nFuture matching findings may appear again.`)) return; + if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.'); + const csrf = cookie('pulse_csrf'); + if (!csrf) throw new Error('No browser CSRF token; stop and sign in normally, without pasting tokens.'); + const response = await fetch(`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`, { + method: 'DELETE', credentials: 'same-origin', redirect: 'error', + headers: { ...headers, 'X-CSRF-Token': csrf }, + }); + if (!response.ok) throw new Error(`Deletion not confirmed (HTTP ${response.status}); stop.`); + if ((await readRules()).some((remaining) => remaining.id === rule.id)) { + throw new Error('Rule is still listed; removal was not verified.'); + } + console.info('Selected rule is no longer listed. Other rules were not deleted.'); +})().catch((error) => console.error(error.message)); +``` + +A failed or lost response is not confirmation of removal. Do not blindly run +the deletion again: refresh the rule list first (open +`/api/ai/patrol/suppressions` in the same signed-in Pulse browser) and check the +selected ID. If the rule is absent, it was already removed. If you cannot +verify the result, stop and report only the HTTP status or error, not cookies, +tokens or the full rule list. Entries whose `created_from` is `finding` or +`dismissed` are individual finding decisions and are deliberately excluded +from this snippet. + Every active finding shown or returned to a Patrol run must receive an explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an all-clear signal. `present` refreshes current evidence, `resolved` remains diff --git a/internal/api/patrol_suppression_management_test.go b/internal/api/patrol_suppression_management_test.go new file mode 100644 index 0000000000..9d2575b31d --- /dev/null +++ b/internal/api/patrol_suppression_management_test.go @@ -0,0 +1,107 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// The rule-removal help uses existing session and CSRF protection, not a token +// pasted into a command. Exercise the actual handlers/store and Router middleware. +func TestPatrolSuppressionManagementBrowserSession(t *testing.T) { + router, session := newRouterWithSession(t) + handler := newTestAISettingsHandler(router.config, config.NewConfigPersistence(router.config.DataPath), nil) + handler.defaultAIService.SetStateProvider(&stubStateProvider{}) + t.Cleanup(handler.defaultAIService.Stop) + patrol := handler.defaultAIService.GetPatrolService() + if patrol == nil { + t.Fatal("expected Patrol service") + } + store := patrol.GetFindings() + selected := store.AddSuppressionRule("vm-1", "Test VM", ai.FindingCategoryBackup, "Test reason") + other := store.AddSuppressionRule("vm-2", "Other VM", ai.FindingCategoryCapacity, "Keep this rule") + router.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(router.config, RequireScope(config.ScopeAIExecute, handler.HandleGetSuppressionRules))) + router.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(router.config, RequireScope(config.ScopeAIExecute, handler.HandleDeleteSuppressionRule))) + + request := func(method, path, csrf string, authenticated bool) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("X-Pulse-Org-ID", "default") + if authenticated { + req.AddCookie(&http.Cookie{Name: "pulse_session", Value: session}) + } + if csrf != "" { + req.Header.Set("X-CSRF-Token", csrf) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + + unauthenticated := request(http.MethodGet, "/api/ai/patrol/suppressions", "", false) + if unauthenticated.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated list status = %d, want 401", unauthenticated.Code) + } + list := request(http.MethodGet, "/api/ai/patrol/suppressions", "", true) + if list.Code != http.StatusOK { + t.Fatalf("authenticated list status = %d, want 200", list.Code) + } + var rules []ai.SuppressionRule + if err := json.Unmarshal(list.Body.Bytes(), &rules); err != nil { + t.Fatal(err) + } + if len(rules) != 2 { + t.Fatalf("listed %d rules, want 2", len(rules)) + } + for _, rule := range rules { + if rule.CreatedFrom != "manual" { + t.Fatalf("created_from = %q, want manual", rule.CreatedFrom) + } + } + csrf := "" + for _, cookie := range list.Result().Cookies() { + if cookie.Name == "pulse_csrf" { + csrf = cookie.Value + } + } + if csrf == "" { + t.Fatal("authenticated GET must issue a browser CSRF cookie") + } + path := "/api/ai/patrol/suppressions/" + selected.ID + for _, token := range []string{"", "invalid-csrf"} { + rec := request(http.MethodDelete, path, token, true) + if rec.Code != http.StatusForbidden || len(store.GetSuppressionRules()) != 2 { + t.Fatalf("invalid-CSRF deletion status = %d; both rules must remain", rec.Code) + } + } + removed := request(http.MethodDelete, path, csrf, true) + if removed.Code != http.StatusOK { + t.Fatalf("deletion status = %d, want 200", removed.Code) + } + if store.MatchesSuppressionRule("vm-1", ai.FindingCategoryBackup) { + t.Fatal("selected rule still suppresses future matching findings") + } + if !store.MatchesSuppressionRule("vm-2", ai.FindingCategoryCapacity) { + t.Fatal("unselected rule must remain effective") + } + readback := request(http.MethodGet, "/api/ai/patrol/suppressions", "", true) + if readback.Code != http.StatusOK { + t.Fatalf("readback status = %d, want 200", readback.Code) + } + if err := json.Unmarshal(readback.Body.Bytes(), &rules); err != nil { + t.Fatal(err) + } + if len(rules) != 1 || rules[0].ID != other.ID { + t.Fatal("readback must retain only the unselected rule") + } + notFound := request(http.MethodDelete, path, csrf, true) + if notFound.Code != http.StatusNotFound || len(store.GetSuppressionRules()) != 1 { + t.Fatalf("already-removed deletion status = %d; remaining rule must survive", notFound.Code) + } +} diff --git a/scripts/tests/test_patrol_rule_help.py b/scripts/tests/test_patrol_rule_help.py new file mode 100644 index 0000000000..2db8927b59 --- /dev/null +++ b/scripts/tests/test_patrol_rule_help.py @@ -0,0 +1,158 @@ +"""Exercise the exact documented browser workaround with synthetic state only.""" + +import json +from pathlib import Path +import re +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +GUIDE = ROOT / "docs" / "AI.md" +SHIPPED_GUIDE = ROOT / "frontend-modern" / "public" / "docs" / "AI.md" + + +def removal_snippet(): + section = GUIDE.read_text().split("#### Remove a rule created by mistake\n", 1)[1] + return re.search(r"```javascript\n(.*?)\n```", section, re.S).group(1) + + +HARNESS = r""" +const vm = require('node:vm'); +let input = ''; +process.stdin.on('data', chunk => input += chunk); +process.stdin.on('end', async () => { + const options = JSON.parse(input); + const calls = [], messages = [], tables = [], confirmations = []; + let cookie = options.cookie ?? 'pulse_org_id=org-a; pulse_csrf=synthetic-csrf'; + const context = { + document: { get cookie() { return cookie; } }, + prompt: () => options.id === undefined ? null : options.id, + confirm: message => { + confirmations.push(message); + if (options.changedOrg) cookie = 'pulse_org_id=org-b; pulse_csrf=synthetic-csrf'; + return options.confirm ?? true; + }, + fetch: async (path, init) => { + calls.push({path, ...init}); + const result = options.responses[calls.length - 1]; + if (!result) throw new Error('Unexpected additional request'); + if (result.networkFailure) throw new Error('Synthetic network failure'); + return {ok: result.status >= 200 && result.status < 300, + status: result.status, json: async () => result.body}; + }, + console: { + table: rows => tables.push(rows), + info: message => messages.push({kind: 'info', message}), + error: message => messages.push({kind: 'error', message}), + }, + }; + await vm.runInNewContext(options.snippet, context, {timeout: 1000}); + process.stdout.write(JSON.stringify({calls, messages, tables, confirmations})); +}); +""" + + +RULE = {"id": "rule-one", "created_from": "manual", "resource_id": "vm-1", + "resource_name": "Test VM", "category": "backup", "description": "Test reason"} +OTHER_RULE = {**RULE, "id": "rule-two", "resource_id": "vm-2"} +DISMISSAL = {**RULE, "id": "finding_one", "created_from": "dismissed"} + + +class PatrolRuleHelpTests(unittest.TestCase): + def run_snippet(self, **options): + options.setdefault("responses", [{"status": 200, "body": [RULE, OTHER_RULE, DISMISSAL]}]) + options["snippet"] = removal_snippet() + result = subprocess.run(["node", "-e", HARNESS], input=json.dumps(options), + text=True, capture_output=True, check=True, timeout=5) + return json.loads(result.stdout) + + def assert_no_delete(self, result): + self.assertFalse(any(call.get("method") == "DELETE" for call in result["calls"])) + self.assertFalse(any(message["kind"] == "info" for message in result["messages"])) + + def test_shipped_help_matches_source(self): + self.assertEqual(GUIDE.read_bytes(), SHIPPED_GUIDE.read_bytes()) + + def test_cancel_is_read_only_and_hides_finding_decisions(self): + result = self.run_snippet() + self.assert_no_delete(result) + self.assertEqual(len(result["calls"]), 1) + self.assertEqual([row["id"] for row in result["tables"][0]], ["rule-one", "rule-two"]) + + def test_unknown_blank_and_finding_ids_cannot_delete(self): + for rule_id in ["missing", " ", "finding_one"]: + with self.subTest(rule_id=rule_id): + self.assert_no_delete(self.run_snippet(id=rule_id)) + + def test_duplicate_id_cannot_delete(self): + self.assert_no_delete(self.run_snippet(id="rule-one", responses=[ + {"status": 200, "body": [RULE, RULE]}])) + + def test_confirmation_cancel_is_read_only(self): + result = self.run_snippet(id="rule-one", confirm=False) + self.assert_no_delete(result) + self.assertIn("Test VM", result["confirmations"][0]) + self.assertIn("backup", result["confirmations"][0]) + self.assertIn("Test reason", result["confirmations"][0]) + + def test_missing_csrf_and_changed_organisation_stop(self): + for options in [{"cookie": "pulse_org_id=org-a"}, {"changedOrg": True}]: + with self.subTest(options=options): + self.assert_no_delete(self.run_snippet(id="rule-one", **options)) + + def test_failed_or_malformed_list_cannot_delete(self): + for response in [{"status": 401}, {"status": 403}, {"status": 200, "body": {}}, + {"networkFailure": True}]: + with self.subTest(response=response): + self.assert_no_delete(self.run_snippet(id="rule-one", responses=[response])) + + def test_delete_failure_or_lost_response_is_not_retried_or_reported_as_success(self): + for response in [{"status": 403}, {"status": 404}, {"status": 503}, + {"networkFailure": True}]: + with self.subTest(response=response): + result = self.run_snippet(id="rule-one", responses=[ + {"status": 200, "body": [RULE, OTHER_RULE]}, response]) + self.assertEqual(len(result["calls"]), 2) + self.assertFalse(any(m["kind"] == "info" for m in result["messages"])) + + def test_readback_must_succeed_and_show_selected_rule_absent(self): + for response in [{"status": 200, "body": [RULE, OTHER_RULE]}, {"status": 403}, + {"networkFailure": True}]: + with self.subTest(response=response): + result = self.run_snippet(id="rule-one", responses=[ + {"status": 200, "body": [RULE, OTHER_RULE]}, {"status": 200}, response]) + self.assertEqual(len(result["calls"]), 3) + self.assertFalse(any(m["kind"] == "info" for m in result["messages"])) + + def test_only_selected_rule_is_deleted_using_bound_browser_context(self): + rule = {**RULE, "id": "rule /?other"} + result = self.run_snippet(id=rule["id"], responses=[ + {"status": 200, "body": [rule, OTHER_RULE, DISMISSAL]}, {"status": 200}, + {"status": 200, "body": [OTHER_RULE, DISMISSAL]}]) + self.assertEqual(len(result["calls"]), 3) + delete = result["calls"][1] + self.assertEqual(delete["path"], "/api/ai/patrol/suppressions/rule%20%2F%3Fother") + self.assertEqual(delete["headers"]["X-CSRF-Token"], "synthetic-csrf") + for call in result["calls"]: + self.assertEqual(call["credentials"], "same-origin") + self.assertEqual(call["redirect"], "error") + self.assertEqual(call["headers"]["X-Pulse-Org-ID"], "org-a") + self.assertNotIn("X-API-Token", call["headers"]) + self.assertEqual(result["messages"][0]["kind"], "info") + self.assertNotIn("synthetic-csrf", json.dumps(result["tables"])) + self.assertNotIn("synthetic-csrf", json.dumps(result["messages"])) + + def test_wildcard_scope_is_explicit_in_list_and_confirmation(self): + rule = {**RULE, "resource_id": "", "resource_name": "", "category": ""} + result = self.run_snippet(id=rule["id"], confirm=False, + responses=[{"status": 200, "body": [rule]}]) + self.assert_no_delete(result) + self.assertEqual(result["tables"][0][0]["resource"], "Any resource") + self.assertEqual(result["tables"][0][0]["category"], "Any category") + self.assertIn("Any resource", result["confirmations"][0]) + self.assertIn("Any category", result["confirmations"][0]) + + +if __name__ == "__main__": + unittest.main() From becd63364a1c9f9af50aec78359c30bd39a75e9c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:15:54 +0100 Subject: [PATCH 2/6] Bind rule-help browser controls before releasing the prompt Await the organisation-cookie change before accepting the synthetic selection prompt and keep generated proof artefacts outside source admission. Contract-Neutral: Proof orchestration only; no runtime or documented rule-removal behaviour changes. Change-source: pulse-maintainer --- .../browser-tests/patrol-rule-help.cjs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/frontend-modern/browser-tests/patrol-rule-help.cjs b/frontend-modern/browser-tests/patrol-rule-help.cjs index 6e08008b36..3df7bb9bc2 100644 --- a/frontend-modern/browser-tests/patrol-rule-help.cjs +++ b/frontend-modern/browser-tests/patrol-rule-help.cjs @@ -11,7 +11,7 @@ const { chromium, webkit } = require('playwright'); const guide = fs.readFileSync('docs/AI.md', 'utf8'); const section = guide.split('#### Remove a rule created by mistake\n')[1]; const snippet = section.match(/```javascript\n([\s\S]*?)\n```/)[1]; -const output = 'frontend-modern/browser-tests/artifacts/patrol-rule-help'; +const output = 'frontend-modern/test-results/patrol-rule-help'; fs.mkdirSync(output, { recursive: true }); const escape = (text) => text.replaceAll('&', '&').replaceAll('<', '<'); const selected = { id: 'rule-one', created_from: 'manual', resource_name: 'Test VM', @@ -92,6 +92,9 @@ const server = http.createServer((request, response) => { page.on('dialog', async (dialog) => { dialogs.push({ type: dialog.type(), message: dialog.message() }); if (dialog.type() === 'prompt') { + if (name === 'changed-org') { + await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]); + } if (name === 'prompt-cancel') await dialog.dismiss(); else await dialog.accept('rule-one'); } else { @@ -100,18 +103,6 @@ const server = http.createServer((request, response) => { } }); await page.goto(url); - if (name === 'changed-org') { - // Change context at the documented confirmation boundary. - const changedSnippet = snippet.replace( - "const csrf = cookie('pulse_csrf');", - "const csrf = cookie('pulse_csrf');"); - // The browser test changes the cookie when the prompt opens, before - // the exact, unchanged snippet reaches its organisation recheck. - page.once('dialog', async () => { - await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]); - }); - assert.equal(changedSnippet, snippet); - } await page.evaluate((code) => window.eval(code), snippet); const deletes = scenario.requests.filter((request) => request.method === 'DELETE'); const success = messages.some((message) => message.kind === 'info'); From ee618fb99366bb76c798f8ada028de85079d4dfa Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:16:03 +0100 Subject: [PATCH 3/6] Guard canonical VM Discovery during guest-read deferrals Use the existing guest safety signals in the canonical resource drawer, including typed and legacy PVE facets. Keep saved Discovery evidence readable and the backup precaution visible without admitting a manual rescan while paused. Contract-Neutral: performance-and-scalability; the workload helper input is narrowed without changing its decisions, metrics, render path or performance contract. Change-source: pulse-maintainer --- .../internal/subsystems/unified-resources.md | 24 ++ .../browser-tests/resource-guest-safety.cjs | 312 ++++++++++++++++++ .../browser-tests/resource-guest-safety.html | 12 + .../browser-tests/resource-guest-safety.tsx | 64 ++++ .../cd3f858e636899f4.json | 45 +++ .../Infrastructure/ResourceDetailDrawer.tsx | 19 ++ .../ResourceDetailDrawerOverviewTab.tsx | 1 + .../ResourceDetailDrawer.discovery.test.ts | 75 ++++- ...ResourceDetailDrawer.guest-safety.test.tsx | 135 ++++++++ .../resourceDetailDiscoveryModel.ts | 28 ++ .../useResourceDetailDrawerDerivedState.ts | 7 +- .../components/Workloads/guestDrawerModel.ts | 24 +- 12 files changed, 740 insertions(+), 6 deletions(-) create mode 100644 frontend-modern/browser-tests/resource-guest-safety.cjs create mode 100644 frontend-modern/browser-tests/resource-guest-safety.html create mode 100644 frontend-modern/browser-tests/resource-guest-safety.tsx create mode 100644 frontend-modern/browser-verification/cd3f858e636899f4.json create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 15c3f338e7..94a3586bfc 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,30 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Canonical Discovery honours VM guest-read deferrals + +The canonical resource drawer uses the same fixed PVE VM lock, backup and +read-deferral signals as the Workloads drawer. Typed and legacy PVE facets are +checked independently: an available flag in one cannot cancel a pause in the +other. Only the VM's own provider evidence is used; parent-node locks, an agent +version and a Discovery target are not guest-liveness evidence. Containers, +ordinary agents and unrelated provider facets retain their existing behaviour. + +Both embedded Analysis and the dedicated Discovery entry pass this pause to the +existing manual-run guard. Saved results remain readable; same-ID updates block +or release the existing control without an automatic scan, invented timestamp +or cancellation claim for already-dispatched work. The wrapping precaution and +existing safety-guide link remain visible across drawer tabs. Clearance is not +independent thaw, covered-filesystem writes or safe monitoring resumption. + +`ResourceDetailDrawer.discovery.test.ts` covers both facets, every fixed reason, +precedence and platform isolation. `ResourceDetailDrawer.guest-safety.test.tsx` +mounts the real Discovery client/control, checks same-ID updates and no dispatch +while paused, and preserves normal agent actions. The production browser fixture +`browser-tests/resource-guest-safety.cjs` checks desktop keyboard and narrow touch +with synthetic local responses. No backend, permission, collector or native +recovery guarantee is added. + ### Broadcast bulk reads capture resources and history coordinates together `ListWithMetricsTargets` and the monitor adapter's matching optional read return diff --git a/frontend-modern/browser-tests/resource-guest-safety.cjs b/frontend-modern/browser-tests/resource-guest-safety.cjs new file mode 100644 index 0000000000..d1d4a4a2b8 --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.cjs @@ -0,0 +1,312 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const output = '/workspace/tmp/resource-guest-safety-settled-proof'; +const paths = [ + 'frontend-modern/src/components/Workloads/guestDrawerModel.ts', + 'frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts', + 'frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts', + 'frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx', + 'frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx', +]; +const reasons = [ + 'vm-locked', + 'lock-unverified', + 'agent-busy', + 'agent-cooldown', + 'agent-response-incomplete', + 'agent-capacity', + 'invalid-guest-key', + 'agent-timeout', +]; +const saved = { + id: 'vm:fixture-node-agent:101', + resource_type: 'vm', + resource_id: '101', + target_id: 'fixture-node-agent', + hostname: 'Backup guest', + service_type: 'home-assistant', + service_name: 'Saved Home Assistant', + service_version: 'test', + category: 'home_automation', + cli_access: '', + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0.9, + ai_reasoning: 'Synthetic saved evidence', + discovered_at: '2026-10-04T12:00:00Z', + updated_at: '2026-10-04T12:00:00Z', + discovery_engine_version: 1, +}; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +(async () => { + fs.mkdirSync(output, { recursive: true }); + const report = { + runtime_sha256: Object.fromEntries(paths.map((p) => [p, hash(path.join('/workspace', p))])), + playwright: require('playwright/package.json').version, + groups: [], + cleanup: {}, + limits: + 'Synthetic local snapshots and HTTP only. No native QGA, diagnostics, backup, thaw, filesystem write, service resumption, physical phone, publication or release acceptance.', + }; + assert.equal( + report.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = + await import('/workspace/frontend-modern/node_modules/vite/dist/node/index.js'); + let server, browser, phase; + try { + for (const [name, engine, width, dark] of [ + ['final-chromium', chromium, 1365, false], + ['final-webkit-phone', webkit, 320, true], + ]) { + phase = name; + const module = '/workspace/frontend-modern'; + process.chdir(module); + server = await createServer({ + root: module, + configFile: path.join(module, 'vite.config.ts'), + cacheDir: path.join(output, name, 'vite'), + server: { + host: '127.0.0.1', + port: 5299, + strictPort: true, + watch: null, + fs: { allow: ['/workspace'] }, + }, + }); + await server.listen(); + const origin = 'http://127.0.0.1:5299'; + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 500 ? 844 : 900 }, + isMobile: width < 500, + hasTouch: width < 500, + locale: 'en-GB', + }); + page.setDefaultTimeout(10000); + const group = { + name, + width, + dark, + browser: browser.version(), + requests: [], + errors: [], + offOrigin: [], + checks: [], + screenshots: [], + }; + report.groups.push(group); + const check = (label) => group.checks.push(label); + const capture = async (label) => { + const file = path.join(output, `${name}-${label}.png`); + await page.screenshot({ path: file, fullPage: true }); + group.screenshots.push({ + path: file.replace('/workspace/', ''), + sha256: hash(file), + bytes: fs.statSync(file).size, + }); + }; + page.on('pageerror', (e) => group.errors.push(e.message)); + await page.routeWebSocket('**/ws*', () => {}); + await page.route('**/*', async (route) => { + const req = route.request(), + url = new URL(req.url()); + if (url.origin !== origin) { + group.offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + group.requests.push({ + path: url.pathname, + query: url.search, + method: req.method(), + body: req.postData(), + }); + if ( + url.pathname === '/api/discovery/vm/fixture-node-agent/101' || + url.pathname === '/api/discovery/agent/fixture-node-agent/101' + ) + return route.fulfill({ json: saved }); + assert.equal(req.method(), 'GET'); + if (url.pathname.startsWith('/api/discovery/info/')) + return route.fulfill({ + json: { + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + }, + }); + if (url.pathname === '/api/ai/agents') + return route.fulfill({ + json: { + count: 1, + agents: [ + { + agent_id: 'fixture-node-agent', + hostname: 'pve-a', + commands_enabled: true, + connected: true, + }, + ], + }, + }); + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + }, + }); + return route.fulfill({ + json: { + capabilities: [], + relationships: [], + recentChanges: [], + counts: {}, + audits: [], + count: 0, + data: [], + enabled: false, + }, + }); + }); + await page.goto(`${origin}/browser-tests/resource-guest-safety.html`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.evaluate((d) => document.documentElement.classList.toggle('dark', d), dark); + const activate = async (locator) => (width < 500 ? locator.tap() : locator.press('Enter')); + await activate(page.getByRole('tab', { name: 'Manage', exact: true })); + const analysis = page.getByTestId('resource-access-analysis'); + await activate(analysis.getByRole('button', { name: 'Open analysis' })); + await analysis.getByText('Saved Home Assistant', { exact: true }).waitFor(); + const button = analysis.getByRole('button', { name: 'Update Discovery' }); + await button.waitFor(); + const posts = () => group.requests.filter((r) => r.method === 'POST'); + const notice = page.getByTestId('resource-guest-read-precaution'); + await notice.waitFor(); + assert(await button.isDisabled()); + assert.match(await notice.innerText(), /An OK backup or a running VM does not prove thaw/); + assert.equal( + await notice.getByRole('link', { name: 'Backup safety guidance' }).getAttribute('href'), + '/docs/VM_DISK_MONITORING', + ); + const control = await button.elementHandle(); + const update = (evidence, legacy = false, type = 'vm') => + page.evaluate( + ({ evidence, legacy, type }) => + window.__resourceGuestSafety.update(evidence, legacy, type), + { evidence, legacy, type }, + ); + for (const legacy of [false, true]) { + for (const reason of reasons) { + for (const diskStatusReason of [reason, `prev-${reason}`]) { + await update({ diskStatusReason, guestAgentStatus: 'available' }, legacy); + assert(await button.isDisabled()); + assert(await notice.isVisible()); + assert.equal(posts().length, 0); + } + } + for (const evidence of [ + { lock: 'backup' }, + { backupInProgress: true }, + { guestAgentStatus: 'deferred' }, + ]) { + await update(evidence, legacy); + assert(await button.isDisabled()); + } + } + assert(await button.evaluate((node, old) => node === old, control)); + assert.equal(await analysis.getByText('Saved Home Assistant', { exact: true }).count(), 1); + check( + 'All 32 reason/facet variants and six operation variants block the same mounted rescan; saved evidence remains readable; zero POST.', + ); + await update({ diskStatusReason: 'prev-agent-timeout' }, true); + const geometry = await notice.boundingBox(); + assert(geometry && geometry.x >= 0 && geometry.x + geometry.width <= width); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)); + const describedBy = await button.getAttribute('aria-describedby'); + assert(describedBy && (await page.locator(`#${describedBy}`).isVisible())); + await capture('paused-saved'); + check('Disabled rescan is described and narrow geometry has no horizontal overflow.'); + await update({ guestAgentStatus: 'available' }); + assert(await button.isEnabled()); + assert.equal(await notice.count(), 0); + assert(await button.evaluate((node, old) => node === old, control)); + assert.equal(posts().length, 0); + check( + 'Healthy same-VM evidence clears the guard without an automatic scan or remount; no thaw claim.', + ); + await capture('cleared'); + const response = page.waitForResponse( + (r) => + r.url() === `${origin}/api/discovery/vm/fixture-node-agent/101` && + r.request().method() === 'POST', + ); + await activate(button); + await response; + assert.equal(posts().length, 1); + assert.equal(posts()[0].path, '/api/discovery/vm/fixture-node-agent/101'); + check( + 'An explicit permitted keyboard/touch action sends exactly one synthetic local request to the unchanged VM target.', + ); + await update({ lock: 'backup' }); + assert(await button.isDisabled()); + await activate(page.getByRole('tab', { name: 'Overview', exact: true })); + assert(await notice.isVisible()); + check('Precaution and guide stay visible on Overview after leaving Analysis.'); + await update({ lock: 'backup' }, false, 'agent'); + await activate(page.getByRole('tab', { name: 'Discovery', exact: true })); + const agentButton = page.getByRole('button', { name: 'Run Discovery', exact: true }); + await agentButton.waitFor(); + assert(await agentButton.isEnabled()); + assert.equal(await notice.count(), 0); + check('Ordinary agent Discovery tab is not paused by parent-side PVE evidence.'); + assert.deepEqual(group.errors, []); + assert.deepEqual(group.offOrigin, []); + check('No page error, off-origin request, native target or background POST.'); + await browser.close(); + browser = null; + await server.close(); + server = null; + } + } catch (error) { + report.failure = { phase, message: error.message }; + throw error; + } finally { + if (browser) await browser.close(); + if (server) await server.close(); + report.cleanup = { browser_closed: true, server_closed: true }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2)); + } + console.log( + JSON.stringify({ + groups: report.groups.length, + checks: report.groups.reduce((n, g) => n + g.checks.length, 0), + screenshots: report.groups.reduce((n, g) => n + g.screenshots.length, 0), + cleanup: report.cleanup, + }), + ); +})().catch((e) => { + console.error(e.stack); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/resource-guest-safety.html b/frontend-modern/browser-tests/resource-guest-safety.html new file mode 100644 index 0000000000..b641a56e4b --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.html @@ -0,0 +1,12 @@ + + + + + + Canonical guest safety evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/resource-guest-safety.tsx b/frontend-modern/browser-tests/resource-guest-safety.tsx new file mode 100644 index 0000000000..dc048daf34 --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.tsx @@ -0,0 +1,64 @@ +// Real canonical drawer/state/Discovery clients; synthetic provider snapshots. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { Resource, ResourceProxmoxMeta } from '../src/types/resource'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: true }); +const healthy: ResourceProxmoxMeta = { + nodeName: 'pve-a', + vmid: 101, + guestAgentStatus: 'available', + lock: '', + diskStatusReason: '', +}; +const resource = ( + evidence: ResourceProxmoxMeta, + legacy = false, + type: Resource['type'] = 'vm', +): Resource => ({ + id: 'fixture:pve-a:101', + type, + name: 'Backup guest', + displayName: 'Backup guest', + platformType: type === 'agent' ? 'agent' : 'proxmox-pve', + platformScopes: type === 'agent' ? ['agent'] : ['proxmox-pve'], + sources: type === 'agent' ? ['agent'] : ['proxmox'], + sourceType: type === 'agent' ? 'agent' : 'api', + status: 'online', + lastSeen: Date.now(), + cpu: { current: 10 }, + memory: { current: 25, used: 1024, total: 4096 }, + discoveryTarget: { + resourceType: type === 'agent' ? 'agent' : 'vm', + agentId: 'fixture-node-agent', + resourceId: '101', + }, + proxmox: legacy ? healthy : { ...healthy, ...evidence }, + platformData: legacy ? { proxmox: evidence } : undefined, +}); +function Fixture() { + const [value, setValue] = createSignal(resource({ lock: 'backup' })); + (window as any).__resourceGuestSafety = { + update: (evidence: ResourceProxmoxMeta, legacy = false, type: Resource['type'] = 'vm') => + setValue(resource(evidence, legacy, type)), + }; + return ( +
+

Canonical guest safety evidence

+

Synthetic local responses. No guest check or backup is sent.

+ +
+ ); +} +render( + () => ( + document.documentElement.classList.contains('dark')}> + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/cd3f858e636899f4.json b/frontend-modern/browser-verification/cd3f858e636899f4.json new file mode 100644 index 0000000000..77cb78f9e7 --- /dev/null +++ b/frontend-modern/browser-verification/cd3f858e636899f4.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "90a7d2e0ad08109132bed4747690868861f67e23", + "verified_at": "2026-10-05T16:15:22.197327Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "3d89b12c95fc35bb93fa703a7d10beb06296fb4ed9b9f0c313f22afc0ff0b6e5", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "969e4051af443b50e45443241a8b4e0f9e66a8f7b51c43d64ab4fd9e5c3f9a84", + "frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts": "7958a719302571e07dde329231554c59f2fa2d3d49852486b54e929df2856af8", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "8abfb347ac32bdd4cb88676999b523f69d8c8f1ea5febd7736a64ba7b57159b5", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "53dc922a8cc70d3e39f51554c581707cd496fa1086290c8ebd6f20fc226933ba" + }, + "routes": [ + "/browser-tests/resource-guest-safety.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 320, + "height": 844 + } + ], + "states": [ + "Real canonical resource drawer/state/Discovery client and CSS, synthetic local snapshots and HTTP only. Parent at the receipt base exposes enabled Update Discovery under backup lock; no parent POST was sent.", + "Desktop Chromium and dark 320px touch WebKit cover every eight fixed reasons, retained variants and typed/legacy facets (32 each), plus backup/lock/deferred signals. A healthy alternate facet never cancels the pause. Same-ID updates retain the mounted control and saved service evidence.", + "First browser attempt exited one on an early dispatch-count assertion after desktop pause/clearance checks; it did not establish dispatch or phone acceptance. Remaining dispatch checks synchronise on the actual POST response; the complete phone matrix and both explicit synthetic actions pass with zero page errors/off-origin requests and closed browser/server.", + "All five captures inspected: parent gap, desktop paused/cleared and phone paused/cleared. Wrapping precaution/guide and saved results fit without horizontal overflow; the disabled rescan has a visible description. Agent Discovery remains usable outside the VM safety condition.", + "This is not native QGA, diagnostics, backup, thaw, covered-filesystem writes, liveness, safe resumption, physical-phone, installation, publication or release acceptance." + ], + "interactions": [ + "Desktop keyboard activation and phone taps use the actual Manage -> Access -> Analysis controls. All paused variants send zero POST; same-ID clearance neither remounts the control nor sends an automatic scan.", + "An explicit healthy VM action sends exactly one synthetic local POST per engine to the existing VM/agent/101 identity. Pause remains visible when leaving Analysis for Overview; ordinary agent Discovery is not paused by host-side PVE evidence.", + "Original desktop matrix/parent observations are retained rather than repeated in the remaining-proof run. Exact executed scripts, results, client exits and screenshot hashes are retained in the lane dossier; production content matches this receipt." + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index 23ea1a0de7..243092e9dd 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -31,6 +31,9 @@ import { ResourceOperatorStateSection } from './ResourceOperatorStateSection'; import { MaintenanceVerificationSection } from './MaintenanceVerificationSection'; import { ResourceActionHistory } from './ResourceActionHistory'; import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from '@/features/docker/dockerCpuPresentation'; +import { InlineNotice } from '@/components/shared/InlineNotice'; +import { GUEST_DRAWER_BACKUP_PRECAUTION } from '@/components/Workloads/guestDrawerModel'; +import { getShippedDocUrl } from '@/utils/docsLinks'; interface ResourceDetailDrawerProps { resource: Resource; @@ -159,6 +162,21 @@ const DrawerContent: Component = (props) => {

+ + {(message) => ( + +

{message()}

+

{GUEST_DRAWER_BACKUP_PRECAUTION}

+
+ )} +
+ = (props) => { canonicalResourceId={props.resource.id} commandsEnabled={drawer.agentMeta()?.commandsEnabled} showManualRunAction + runBlockReason={drawer.guestReadPrecaution()} /> )} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index d58b0d7cb3..75ac3bb823 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -337,6 +337,7 @@ export const ResourceAccessDisclosure: Component<{ hostname={config().hostname} canonicalResourceId={props.resource.id} commandsEnabled={props.drawer.agentMeta()?.commandsEnabled} + runBlockReason={props.drawer.guestReadPrecaution()} /> diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index bfa830187a..f13a976d3e 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -5,7 +5,11 @@ import type { ResourceDiscovery } from '@/types/discovery'; import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw'; import discoveryReadinessSource from '@/components/Discovery/discoveryReadiness.ts?raw'; import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState.ts?raw'; -import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel'; +import { + getResourceGuestReadPrecaution, + toDiscoveryConfig, +} from '@/components/Infrastructure/resourceDetailDiscoveryModel'; +import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals'; import { hasMeaningfulDiscoveryContext } from '@/utils/discoveryPresentation'; const baseResource = (): Resource => ({ @@ -28,6 +32,75 @@ const baseResource = (): Resource => ({ }, }); +describe('canonical VM guest-read safety', () => { + const vm = (): Resource => ({ ...baseResource(), type: 'vm' }); + + it.each(guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]))( + 'honours %s from typed and legacy PVE evidence independently', + (diskStatusReason) => { + for (const facet of ['typed', 'legacy']) { + const evidence = { diskStatusReason, guestAgentStatus: 'available' }; + const value = vm(); + if (facet === 'typed') value.proxmox = evidence; + else value.platformData = { proxmox: evidence }; + expect(getResourceGuestReadPrecaution(value)).toBeTruthy(); + expect(getResourceGuestReadPrecaution(value)).not.toContain(diskStatusReason); + } + }, + ); + + it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])( + 'does not let a healthy alternate facet cancel %j', + (paused) => { + const healthy = { guestAgentStatus: 'available', diskStatusReason: '', lock: '' }; + expect( + getResourceGuestReadPrecaution({ + ...vm(), + proxmox: healthy, + platformData: { proxmox: paused }, + }), + ).toBeTruthy(); + expect( + getResourceGuestReadPrecaution({ + ...vm(), + proxmox: paused, + platformData: { proxmox: healthy }, + }), + ).toBeTruthy(); + }, + ); + + it.each(['agent', 'system-container', 'app-container'] as const)( + 'does not turn parent or container PVE evidence into a %s QGA pause', + (type) => { + expect( + getResourceGuestReadPrecaution({ ...vm(), type, proxmox: { lock: 'backup' } }), + ).toBeNull(); + }, + ); + + it('leaves healthy/unrelated VMs alone and withholds unknown raw provider text', () => { + expect(getResourceGuestReadPrecaution(vm())).toBeNull(); + expect( + getResourceGuestReadPrecaution({ ...vm(), proxmox: { guestAgentStatus: 'available' } }), + ).toBeNull(); + expect( + getResourceGuestReadPrecaution({ + ...vm(), + platformType: 'vmware-vsphere', + platformData: { vmware: { lock: 'backup' } }, + }), + ).toBeNull(); + expect( + getResourceGuestReadPrecaution({ + ...vm(), + proxmox: { lock: 'private provider text', diskStatusReason: 'private read reason' }, + }), + ).not.toMatch(/private provider text|private read reason/); + expect(getResourceGuestReadPrecaution({ ...vm(), platformData: { proxmox: [] } })).toBeNull(); + }); +}); + describe('toDiscoveryConfig', () => { it('prefers backend discoveryTarget over heuristic IDs', () => { const resource: Resource = { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx new file mode 100644 index 0000000000..3f3a1ced6b --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx @@ -0,0 +1,135 @@ +import { createSignal, Suspense } from 'solid-js'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { ResourceDetailDrawer } from '../ResourceDetailDrawer'; +import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals'; +import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import * as discoveryApi from '@/api/discovery'; +import { getShippedDocUrl } from '@/utils/docsLinks'; + +vi.mock('@/api/discovery', async (importOriginal) => ({ + ...(await importOriginal()), + getDiscovery: vi.fn(async () => null), + getDiscoveryInfo: vi.fn(async () => ({ + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + })), + getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), + triggerDiscovery: vi.fn(async () => null), +})); +vi.mock('@/api/resources', async (importOriginal) => ({ + ...(await importOriginal()), + ResourceAPI: { + getFacetBundle: vi.fn(async () => ({ + capabilities: [], + relationships: [], + recentChanges: [], + counts: {}, + })), + }, +})); +vi.mock('@/api/ai', () => ({ AIAPI: { getResourceIntelligence: vi.fn(async () => null) } })); +vi.mock('@/api/actionAudit', () => ({ + ActionAuditAPI: { + listActionAudits: vi.fn(async () => ({ audits: [], count: 0, available: false })), + }, +})); + +const vm = (patch: Partial = {}): Resource => ({ + id: 'fixture:pve1:101', + type: 'vm', + name: 'Backup guest', + status: 'online', + sourceType: 'api', + platformType: 'proxmox-pve', + lastSeen: 1, + proxmox: { nodeName: 'pve1', vmid: 101, guestAgentStatus: 'available' }, + discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '101' }, + ...patch, +}); +const mount = (value: () => Resource) => + render(() => ( + + + + )); +const openAnalysis = async () => { + fireEvent.click(await screen.findByRole('tab', { name: 'Manage' })); + fireEvent.click(await screen.findByRole('button', { name: 'Open analysis' })); + return within(screen.getByTestId('resource-access-analysis')).findByRole('button', { + name: 'Run Discovery', + }); +}; + +beforeEach(() => { + resetAIRuntimeState(); + syncAIRuntimeSettings({ discovery_enabled: true }); + resetCreateNonSuspendingQueryCacheForTest(); +}); +afterEach(() => { + cleanup(); + resetAIRuntimeState(); + vi.clearAllMocks(); +}); + +describe('canonical drawer real Discovery safety wiring', () => { + it('blocks every retained/current deferral reactively without remounting or dispatching a scan', async () => { + const [value, setValue] = createSignal(vm({ proxmox: { lock: 'backup' } })); + mount(value); + const button = await openAnalysis(); + await waitFor(() => expect(button).toBeDisabled()); + expect(screen.getByTestId('resource-guest-read-precaution')).toBeVisible(); + expect( + within(screen.getByTestId('resource-guest-read-precaution')).getByRole('link', { + name: 'Backup safety guidance', + }), + ).toHaveAttribute('href', getShippedDocUrl('VM_DISK_MONITORING.md')); + for (const [reason] of guestDiskDeferrals) { + for (const diskStatusReason of [reason, `prev-${reason}`]) { + setValue(vm({ proxmox: { diskStatusReason, guestAgentStatus: 'available' } })); + await waitFor(() => expect(button).toBeDisabled()); + fireEvent.click(button); + } + } + setValue(vm({ platformData: { proxmox: { backupInProgress: true } } })); + await waitFor(() => expect(button).toBeDisabled()); + setValue(vm()); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.queryByTestId('resource-guest-read-precaution')).not.toBeInTheDocument(); + expect( + within(screen.getByTestId('resource-access-analysis')).getByRole('button', { + name: 'Run Discovery', + }), + ).toBe(button); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + expect(discoveryApi.triggerDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '101', { + force: true, + hostname: 'Backup guest', + }); + }); + + it('keeps ordinary agent Discovery tab actions usable despite host-side PVE locks', async () => { + mount(() => + vm({ + id: 'node-agent', + type: 'agent', + sourceType: 'agent', + platformType: 'agent', + sources: ['agent'], + proxmox: { lock: 'backup' }, + discoveryTarget: { resourceType: 'agent', agentId: 'node-agent', resourceId: 'node-agent' }, + }), + ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.queryByTestId('resource-guest-read-precaution')).not.toBeInTheDocument(); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts index c7c915a6e7..d9a5dbd404 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts @@ -1,4 +1,5 @@ import type { Resource } from '@/types/resource'; +import { getGuestDrawerGuestReadPrecaution } from '@/components/Workloads/guestDrawerModel'; import type { ResourceType as DiscoveryResourceType } from '@/types/discovery'; import { canonicalDiscoveryResourceType, @@ -64,6 +65,33 @@ type PlatformData = { const asString = (value: unknown): string | undefined => typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +// The canonical drawer has both typed and legacy provider facets. A healthy +// facet cannot cancel a deferral still reported by the other one. Inspect only +// this VM's own PVE evidence, not its parent, Discovery target or cached version. +export const getResourceGuestReadPrecaution = (resource: Resource): string | null => { + if (resource.type !== 'vm') return null; + const legacy = resource.platformData?.proxmox; + const facets = [ + resource.proxmox, + legacy && typeof legacy === 'object' && !Array.isArray(legacy) + ? (legacy as Record) + : undefined, + ]; + for (const facet of facets) { + if (!facet) continue; + const precaution = getGuestDrawerGuestReadPrecaution({ + type: 'vm', + platformScopes: ['proxmox-pve'], + guestAgentStatus: asString(facet.guestAgentStatus), + diskStatusReason: asString(facet.diskStatusReason), + lock: asString(facet.lock), + backupInProgress: facet.backupInProgress === true, + }); + if (precaution) return precaution; + } + return null; +}; + const asNumber = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 0131b0df70..66e329b8e3 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -41,7 +41,10 @@ import { type KubernetesPlatformData, type PlatformData, } from '@/components/Infrastructure/resourceDetailMappers'; -import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel'; +import { + getResourceGuestReadPrecaution, + toDiscoveryConfig, +} from '@/components/Infrastructure/resourceDetailDiscoveryModel'; import { getDiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation'; import { buildPbsActiveTasks, @@ -298,6 +301,7 @@ export const useResourceDetailDrawerDerivedState = ( const hasAliasOverflow = createMemo(() => identityView().hasAliasOverflow); const hasMergedSources = createMemo(() => mergedSources().length > 1); const discoveryConfig = createMemo(() => toDiscoveryConfig(resource())); + const guestReadPrecaution = createMemo(() => getResourceGuestReadPrecaution(resource())); const discoveryContextSummary = createMemo(() => buildDiscoveryContextSummary(discoveryConfig())); const discoverySourceKey = createMemo(() => { if (!discoveryFeatureEnabled()) return null; @@ -517,6 +521,7 @@ export const useResourceDetailDrawerDerivedState = ( metricsHistoryGroups, hasMetricsHistory, hasDiscoveryTab, + guestReadPrecaution, hasVMwareDetails, vmwareDetailsSummary, vmwareDetailSections, diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 974a0b8f84..5874731709 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -439,6 +439,21 @@ export interface GuestDrawerGuestReadPresentation { precaution: boolean; } +// Both drawer shapes use the same provider-owned signals. A canonical resource +// need not fabricate a complete workload (or infer liveness from metrics). +export type GuestDrawerGuestReadEvidence = Pick< + Guest, + | 'type' + | 'workloadType' + | 'agentKind' + | 'platformType' + | 'platformScopes' + | 'guestAgentStatus' + | 'diskStatusReason' + | 'lock' + | 'backupInProgress' +>; + export const GUEST_DRAWER_BACKUP_PRECAUTION = 'Do not run live diagnostics or restart the guest agent during a backup, freeze/thaw or an unresponsive-guest incident. An OK backup or a running VM does not prove thaw. Confirm thaw and writes to the filesystems covered by the backup independently.'; @@ -457,9 +472,9 @@ const guestReadDeferrals = new Set([ // parent-agent action target establishes QGA liveness. Use only the current // provider state and fixed read reasons; never print an unknown raw value. export const getGuestDrawerGuestReadPresentation = ( - guest: Guest, + guest: GuestDrawerGuestReadEvidence, ): GuestDrawerGuestReadPresentation | null => { - if (!isGuestDrawerVM(guest)) return null; + if (resolveWorkloadType(guest) !== 'vm') return null; const hasProxmoxEvidence = guest.type === 'qemu' || guest.agentKind === 'qemu-guest' || @@ -545,8 +560,9 @@ export const getGuestDrawerGuestReadPresentation = ( // A read-specific precaution is independent of the reported availability flag. // For example Proxmox can report QGA enabled while its last request timed out. -export const getGuestDrawerGuestReadPrecaution = (guest: Guest): string | null => { - if (!isGuestDrawerVM(guest)) return null; +export const getGuestDrawerGuestReadPrecaution = ( + guest: GuestDrawerGuestReadEvidence, +): string | null => { const presentation = getGuestDrawerGuestReadPresentation(guest); if (!presentation?.precaution) return null; const reason = (guest.diskStatusReason || '').replace(/^prev-/, ''); From b27b6b0836273fbe8ad6803cdd012a964560bc8c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:24:27 +0100 Subject: [PATCH 4/6] Keep rule-help browser fixtures on their intended request paths Serve a harmless 404 for browser favicon requests and bind synthetic session requests to their recorded user agent. Retain the earlier browser setup failure. Contract-Neutral: Synthetic proof-fixture preparation only; no runtime or rule-removal instructions changed. Change-source: pulse-maintainer --- frontend-modern/browser-tests/patrol-rule-help.cjs | 5 ++++- internal/api/patrol_suppression_management_test.go | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend-modern/browser-tests/patrol-rule-help.cjs b/frontend-modern/browser-tests/patrol-rule-help.cjs index 3df7bb9bc2..1f5ff4f0fe 100644 --- a/frontend-modern/browser-tests/patrol-rule-help.cjs +++ b/frontend-modern/browser-tests/patrol-rule-help.cjs @@ -32,7 +32,10 @@ const server = http.createServer((request, response) => {
${escape(snippet)}
`); return; } - assert.ok(request.url.startsWith('/api/ai/patrol/suppressions')); + if (!request.url.startsWith('/api/ai/patrol/suppressions')) { + response.writeHead(404).end(); + return; + } const hasSession = (request.headers.cookie || '').includes('pulse_session=synthetic-session'); const expectedOrg = request.headers['x-pulse-org-id'] === 'org-a'; const validCSRF = request.headers['x-csrf-token'] === 'synthetic-csrf'; diff --git a/internal/api/patrol_suppression_management_test.go b/internal/api/patrol_suppression_management_test.go index 9d2575b31d..1add39ed89 100644 --- a/internal/api/patrol_suppression_management_test.go +++ b/internal/api/patrol_suppression_management_test.go @@ -31,6 +31,7 @@ func TestPatrolSuppressionManagementBrowserSession(t *testing.T) { t.Helper() req := httptest.NewRequest(method, path, nil) req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("User-Agent", "test-agent") req.Header.Set("X-Requested-With", "XMLHttpRequest") req.Header.Set("X-Pulse-Org-ID", "default") if authenticated { From ce2111c089b451fb59f410f180b979ba95619b1c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:41:55 +0100 Subject: [PATCH 5/6] Keep shared guest-read evidence optional across workload types Correct the union input identified by source-9p3hq3rc without changing guest-read decisions. Keep required resource identity fields explicit in the new test fixtures. Contract-Neutral: performance-and-scalability; type-only optional guest-read input correction and typed fixture fields, with unchanged runtime decisions, metrics and performance contract. Change-source: pulse-maintainer --- .../browser-tests/resource-guest-safety.tsx | 1 + ...ResourceDetailDrawer.guest-safety.test.tsx | 4 +++- .../components/Workloads/guestDrawerModel.ts | 19 ++++++++----------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend-modern/browser-tests/resource-guest-safety.tsx b/frontend-modern/browser-tests/resource-guest-safety.tsx index dc048daf34..b307a6fd2e 100644 --- a/frontend-modern/browser-tests/resource-guest-safety.tsx +++ b/frontend-modern/browser-tests/resource-guest-safety.tsx @@ -24,6 +24,7 @@ const resource = ( type, name: 'Backup guest', displayName: 'Backup guest', + platformId: 'fixture', platformType: type === 'agent' ? 'agent' : 'proxmox-pve', platformScopes: type === 'agent' ? ['agent'] : ['proxmox-pve'], sources: type === 'agent' ? ['agent'] : ['proxmox'], diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx index 3f3a1ced6b..6651b5f197 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx @@ -42,6 +42,8 @@ const vm = (patch: Partial = {}): Resource => ({ id: 'fixture:pve1:101', type: 'vm', name: 'Backup guest', + displayName: 'Backup guest', + platformId: 'fixture', status: 'online', sourceType: 'api', platformType: 'proxmox-pve', @@ -66,7 +68,7 @@ const openAnalysis = async () => { beforeEach(() => { resetAIRuntimeState(); - syncAIRuntimeSettings({ discovery_enabled: true }); + syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); resetCreateNonSuspendingQueryCacheForTest(); }); afterEach(() => { diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 5874731709..f78290928c 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -441,18 +441,15 @@ export interface GuestDrawerGuestReadPresentation { // Both drawer shapes use the same provider-owned signals. A canonical resource // need not fabricate a complete workload (or infer liveness from metrics). -export type GuestDrawerGuestReadEvidence = Pick< +export interface GuestDrawerGuestReadEvidence extends Pick< Guest, - | 'type' - | 'workloadType' - | 'agentKind' - | 'platformType' - | 'platformScopes' - | 'guestAgentStatus' - | 'diskStatusReason' - | 'lock' - | 'backupInProgress' ->; + 'type' | 'workloadType' | 'agentKind' | 'platformType' | 'platformScopes' +> { + diskStatusReason?: VM['diskStatusReason']; + guestAgentStatus?: VM['guestAgentStatus']; + lock?: VM['lock']; + backupInProgress?: VM['backupInProgress']; +} export const GUEST_DRAWER_BACKUP_PRECAUTION = 'Do not run live diagnostics or restart the guest agent during a backup, freeze/thaw or an unresponsive-guest incident. An OK backup or a running VM does not prove thaw. Confirm thaw and writes to the filesystems covered by the backup independently.'; From 8abe8d4744c486bdc7b78e3bb13c08c5fd8c725f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:00:08 +0100 Subject: [PATCH 6/6] Record final guest-read safety browser proof Fresh desktop and narrow-screen checks verify the corrected shared input against this commit parent. This proof-only commit changes no production frontend content. Change-source: pulse-maintainer --- .../e1e985a3fee492c0.json | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 frontend-modern/browser-verification/e1e985a3fee492c0.json diff --git a/frontend-modern/browser-verification/e1e985a3fee492c0.json b/frontend-modern/browser-verification/e1e985a3fee492c0.json new file mode 100644 index 0000000000..f58aa5fdaf --- /dev/null +++ b/frontend-modern/browser-verification/e1e985a3fee492c0.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "base_sha": "ce2111c089b451fb59f410f180b979ba95619b1c", + "verified_at": "2026-10-05T16:59:02.182220Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "0b5db44ee2a4f393ebac64cc9900721a3dac880b10f0b0cf1fd43b9eb07f0304" + }, + "routes": [ + "/browser-tests/resource-guest-safety.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 320, + "height": 844 + } + ], + "states": [ + "Fresh final-content checks after the type-only shared input correction. Real canonical drawer/Discovery client and CSS with synthetic local HTTP only; Chromium 141.0.7390.37 and dark touch WebKit 26.0, Playwright 1.56.1 matches the integration lock.", + "Both viewports cover empty saved data during backup, same-ID clearance with no automatic scan, then retained legacy timeout with reported availability re-pausing the same control. Three checks per engine, no POST, page errors or off-origin requests; browser/server closure confirmed.", + "Both captures inspected: backup-safety notice and guide wrap beside a visible, disabled Discovery control; the 320px page has no horizontal overflow.", + "Two earlier phone admissions returned gateway restarting before execution and no operation started. This successful fresh admission followed the completed corrected-source proof; it does not diagnose the restart or clear earlier stopped/unknown browser operations.", + "The earlier own-parent receipt remains valid for the four unchanged canonical production files and their complete saved-result/explicit-action matrix. This receipt verifies only the changed shared helper final content.", + "No native QGA, backup, thaw, every-covered-filesystem writes, liveness, prior-active-only resumption, physical phone, installation, publication or release acceptance." + ], + "interactions": [ + "Keyboard Manage -> Open analysis; reactive same-ID provider snapshots retain the actual Discovery control and revoke/restore its disabled state without dispatch.", + "Desktop and phone capture/DOM checks use the exact optional-evidence helper content hashed here. No earlier completed matrix or unavailable executed operation was replayed." + ] +}