Keep VM Discovery safe during backups and document precise Patrol rule removal (#2533)

## Outcome and need

Keep the canonical VM drawer from offering a manual Discovery rescan
while its own guest-read evidence reports a backup or deferral. The
Workloads drawer already applied this precaution, but the canonical
Manage → Access → Analysis path did not. Typed and retained legacy PVE
evidence now independently pause both Discovery mounts; a healthy
alternate facet cannot cancel a reported deferral. Saved results stay
readable, the backup-safety guidance stays visible across tabs, and
clearance does not automatically dispatch a scan. Ordinary agent
Discovery remains usable.

Also document how an authorised signed-in administrator can remove
exactly one mistakenly created manual Patrol rule through the existing
API. Discussion 2523 exposed a missing rule-management UI: Reopen undoes
a finding dismissal, not a separately created permanent rule. The guide
selects and confirms an exact manual ID, preserves session/CSRF and
organisation scope, stops on errors or uncertainty, and verifies
readback without requesting pasted credentials or deleting unrelated
rules/history. This is a workaround, not the missing UI repair or proof
of a reporter's deletion.

Related to #2439

Related to #2528

Related to https://github.com/rcourtman/Pulse/discussions/2523

## Included source

- Web candidate `20261005T154017Z-web-product`: three commits
`ee618fb99366bb76c798f8ada028de85079d4dfa` through
`8abe8d4744c486bdc7b78e3bb13c08c5fd8c725f`, over `90a7d2e0`.
- Product-intelligence candidate
`20261005T154825Z-product-intelligence`: three commits
`310c7e6e5195271efc8200aebb3f32feef687c2c` through
`b27b6b0836273fbe8ad6803cdd012a964560bc8c`, over `d916f416`.
- Reviewed integrations `377aea8ad55804e9f469bea05f89e1aa616ce6db` and
`53ec5fae93d7d65c9815ee09d868dc9c7fb5cd19` retain both exact candidates.
Additive upstream composition `bd95e64bf2fc24f3cabf0468784979d8da5ad636`
includes current upstream without source conflicts or rewriting accepted
commits.
- Proposed range:
`690d0ef31ee47c6a56009a8fd827e87451517721..bd95e64bf2fc24f3cabf0468784979d8da5ad636`.

## Validation and remaining limits

- Recorded Web proof `source-8ah5ki7c`: final implementation types,
lint, build, bundle budgets and full frontend suite pass (22,761 tests;
three skipped). Parent-bound desktop Chromium and narrow dark WebKit
controls cover pause/clearance, retained saved evidence and explicit
synthetic actions. The two committed browser receipts hash-match all
five final production files, including the final type-only correction.
- Recorded guidance proof `source-ni_ulklt`: eleven exact-snippet tests,
real frontend types/build/embed and focused authenticated API/store
races pass, with complete output and completed guest stop. Help, API
implementation, primary dependency manifests and relevant regression
tests remain unchanged. The guide's separate final browser admission was
busy before execution; no installed browser deletion is claimed.
- Publisher final-tree reconciliation proof: `source-q5pr3mwf` on the
exact proposed head passes types, five connected guest-safety/helper
test files (153 tests), eleven exact-snippet tests, the real embed build
and bundle budgets. Guest/client exit 0, complete 35,220-byte raw output
independently hash/count-matched, and completed guest stop. Readiness
connect/synchronisation errors preceded successful readiness; the
line-bound flag remains true.
- Publication provenance, clean-tree, whitespace and retained/upstream
ancestry checks pass. Sufficient full-suite and browser evidence was
reused rather than rerun just because the candidates share a batch.
- The pre-existing docs-policy literal assertion failure remains; its
affected text/test are unchanged. Earlier setup, type and
browser-orchestration failures remain recorded alongside their
corrections, not counted as passes.

Exact-head independent review and required GitHub checks still precede
landing. The Discovery UI precaution does not establish
all-path/shared/in-flight/restart-safe backend admission, independent
thaw, fresh writes on every covered filesystem, liveness or
prior-active-only resumption. Those native recovery obligations and the
monitoring-outage precaution remain. Publication establishes neither
installed acceptance nor release readiness, and the Patrol UI repair
remains separate.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-10-05 18:24:21 +00:00
committed by GitHub
18 changed files with 1365 additions and 8 deletions
+84 -1
View File
@@ -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
@@ -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
@@ -0,0 +1,154 @@
// 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/test-results/patrol-rule-help';
fs.mkdirSync(output, { recursive: true });
const escape = (text) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;');
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(`<h1>Rule removal help — synthetic browser check</h1>
<p>Cancel keeps both rules. A confirmed removal targets one exact ID.</p>
<p id="result">No removal confirmed</p>
<pre style="white-space:pre-wrap;max-width:1000px">${escape(snippet)}</pre>`);
return;
}
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';
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 === '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 {
if (name === 'confirm-cancel') await dialog.dismiss();
else await dialog.accept();
}
});
await page.goto(url);
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; });
@@ -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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Canonical guest safety evidence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/resource-guest-safety.tsx"></script>
</body>
</html>
@@ -0,0 +1,65 @@
// 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',
platformId: 'fixture',
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 (
<main class="mx-auto min-h-screen max-w-5xl space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Canonical guest safety evidence</h1>
<p class="text-xs text-muted">Synthetic local responses. No guest check or backup is sent.</p>
<ResourceDetailDrawer resource={value()} initialShowAccessContext />
</main>
);
}
render(
() => (
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<Fixture />
</DarkModeContext.Provider>
),
document.getElementById('root')!,
);
@@ -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."
]
}
@@ -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."
]
}
+84 -1
View File
@@ -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
@@ -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<ResourceDetailDrawerProps> = (props) => {
</p>
</Show>
<Show when={drawer.guestReadPrecaution()}>
{(message) => (
<InlineNotice
tone="warning"
role="status"
data-testid="resource-guest-read-precaution"
actionHref={getShippedDocUrl('VM_DISK_MONITORING.md')}
actionLabel="Backup safety guidance"
>
<p>{message()}</p>
<p>{GUEST_DRAWER_BACKUP_PRECAUTION}</p>
</InlineNotice>
)}
</Show>
<Subtabs
class="mb-1"
ariaLabel="Resource detail sections"
@@ -262,6 +280,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
canonicalResourceId={props.resource.id}
commandsEnabled={drawer.agentMeta()?.commandsEnabled}
showManualRunAction
runBlockReason={drawer.guestReadPrecaution()}
/>
</Suspense>
)}
@@ -337,6 +337,7 @@ export const ResourceAccessDisclosure: Component<{
hostname={config().hostname}
canonicalResourceId={props.resource.id}
commandsEnabled={props.drawer.agentMeta()?.commandsEnabled}
runBlockReason={props.drawer.guestReadPrecaution()}
/>
</Suspense>
</Show>
@@ -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 = {
@@ -0,0 +1,137 @@
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<typeof import('@/api/discovery')>()),
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<typeof import('@/api/resources')>()),
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> = {}): Resource => ({
id: 'fixture:pve1:101',
type: 'vm',
name: 'Backup guest',
displayName: 'Backup guest',
platformId: 'fixture',
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(() => (
<Suspense>
<ResourceDetailDrawer resource={value()} initialShowAccessContext />
</Suspense>
));
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 } as Parameters<typeof syncAIRuntimeSettings>[0]);
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));
});
});
@@ -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<string, unknown>)
: 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;
@@ -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,
@@ -439,6 +439,18 @@ 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 interface GuestDrawerGuestReadEvidence extends Pick<
Guest,
'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.';
@@ -457,9 +469,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 +557,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-/, '');
@@ -0,0 +1,108 @@
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("User-Agent", "test-agent")
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)
}
}
+158
View File
@@ -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()