Files
pulse/scripts/check-host-disk-patrol.mjs
rcourtman 19d016d7df fix(patrol): resolve disk alerts through canonical resource owners
Host filesystem alerts identify a disk child that Patrol does not collect as
a separate resource. Bind an exact collected alert to its current canonical
owner through the shared inventory contract, including linked agents, and
retain its filesystem, threshold and breach evidence in the scoped prompt.

Keep missing owners, ambiguous source identities and uncollected child
subjects fail-closed. Add evaluator-to-handler, repeated scope resolution
and browser admission coverage.
2026-10-08 17:22:29 +01:00

121 lines
8.6 KiB
JavaScript

// Admission/UI contract proof with a production-emitted disk alert.
// GET estate/session data is synthetic. Patrol POSTs use the current Go handler
// from TestPatrolHostDiskBrowserBackend. No inference or infrastructure writes.
import { chromium, expect } from '../tests/integration/node_modules/@playwright/test/index.mjs';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
const base = process.env.PLAYWRIGHT_BASE_URL;
const backend = process.env.PULSE_HOST_DISK_BROWSER_URL;
if (!base || !backend) throw new Error('Set PLAYWRIGHT_BASE_URL and PULSE_HOST_DISK_BROWSER_URL to isolated loopback servers');
for (const url of [base, backend]) {
if (new URL(url).hostname !== '127.0.0.1') throw new Error('Proof servers must use loopback');
}
const output = new URL('../tmp/host-disk-patrol/', import.meta.url);
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true });
const results = [];
const errors = [];
const diagnostics = [];
let lastPage;
try {
const request = await browser.newContext();
const alert = await (await request.request.get(`${backend}/proof/alert`)).json();
await request.close();
expect(alert.resourceId).toBe('agent:repro-host-source-01/disk:docker-data');
expect(alert.value).toBe(85);
expect(alert.threshold).toBe(85);
for (const width of [1440, 390]) {
const context = await browser.newContext({ viewport: { width, height: 1000 } });
const page = await context.newPage();
lastPage = page;
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') diagnostics.push(message.text());
});
page.on('requestfailed', (request) => diagnostics.push(`${request.url()}: ${request.failure()?.errorText}`));
let mode = 'accepted';
const admissions = [];
const state = { nodes: [], vms: [], containers: [], dockerHosts: [], hosts: [], storage: [], resources: [], activeAlerts: [alert], recentlyResolved: [], connectionHealth: {}, timestamp: Date.now() };
await page.routeWebSocket('**/ws**', (socket) => socket.send(JSON.stringify({ type: 'initialState', data: state })));
await page.route('**/api/**', async (route) => {
const req = route.request();
const path = new URL(req.url()).pathname;
// Vite also serves source modules below /src/api/. Only stub HTTP API
// endpoints; module requests must load the actual application source.
if (!path.startsWith('/api/')) return route.continue();
if (path === '/api/ai/patrol/run' && req.method() === 'POST') {
const body = req.postDataJSON();
expect(body).toEqual({ resource_ids: [alert.resourceId], alert_identifier: alert.id, alert_type: 'disk' });
const response = await route.fetch({ url: `${backend}${path}?case=${mode}` });
admissions.push({ mode, body, status: response.status(), response: await response.json() });
return route.fulfill({ response });
}
if (req.method() !== 'GET') return route.fulfill({ status: 403, json: { error: 'Other writes disabled in proof' } });
if (path === '/api/version') return route.fulfill({ json: { version: '6.5.0', build: 'proof', runtime: 'development', isDocker: false, isSourceBuild: true, isDevelopment: true } });
if (path === '/api/updates/check') return route.fulfill({ json: { available: false, currentVersion: '6.5.0', latestVersion: '6.5.0', releaseNotes: '', downloadUrl: '', isPrerelease: false, isMajorUpgrade: false } });
if (path === '/api/updates/status') return route.fulfill({ json: { status: 'idle', progress: 0, message: '', updatedAt: new Date().toISOString() } });
if (path === '/api/security/status') return route.fulfill({ json: { hasAuthentication: true, hasProxyAuth: true, proxyAuthUsername: 'proof-operator', sessionCapabilities: { assistantEnabled: true, settingsRead: true, settingsWrite: true } } });
if (path === '/api/license/runtime-capabilities') return route.fulfill({ json: { capabilities: ['ai_patrol', 'sso'], limits: [], blocked_capabilities: [], hosted_mode: false } });
if (path === '/api/orgs') return route.fulfill({ json: [{ id: 'default', displayName: 'Proof' }] });
if (path === '/api/resources') return route.fulfill({ json: { data: [], aggregations: { platformAdmission: { proxmox: false, docker: false, kubernetes: false, truenas: false, vmware: false, standalone: true } } } });
if (path === '/api/state') return route.fulfill({ json: state });
if (path === '/api/alerts/active') return route.fulfill({ json: [alert] });
if (path === '/api/alerts/config') return route.fulfill({ json: { enabled: true, activationState: 'active', nodeDefaults: {}, guestDefaults: {}, agentDefaults: {}, overrides: {}, timeThresholds: {}, notifications: {} } });
if (path === '/api/ai/settings') return route.fulfill({ json: { enabled: true, model: 'fixture:admission', control_level: 'read_only' } });
if (/\/(history|events|delivery-diagnosis|sessions|models|findings|incidents)$/.test(path)) return route.fulfill({ json: [] });
return route.fulfill({ json: {} });
});
await page.goto(`${base}/alerts`, { waitUntil: 'domcontentloaded' });
const button = page.getByTitle('Have Patrol investigate this alert', { exact: true });
await expect(button).toBeVisible({ timeout: 30000 });
await button.scrollIntoViewIfNeeded();
const rect = await button.boundingBox();
expect(rect.x).toBeGreaterThanOrEqual(0);
expect(rect.x + rect.width).toBeLessThanOrEqual(width);
await page.screenshot({ path: new URL(`default-${width}.png`, output).pathname, fullPage: true });
const menu = page.getByRole('button', { name: 'More alert actions', exact: true });
await menu.click();
await page.screenshot({ path: new URL(`menu-${width}.png`, output).pathname, fullPage: true });
await page.keyboard.press('Escape');
await expect(menu).toHaveAttribute('aria-expanded', 'false');
await menu.click();
await page.getByText('Active Alerts', { exact: true }).first().click();
await expect(menu).toHaveAttribute('aria-expanded', 'false');
for (const [scenario, status, text] of [
['accepted', 200, `Patrol is investigating ${alert.resourceName}`],
['busy', 409, 'Patrol is already running.'],
['unresolved', 422, 'One or more requested resource identities could not be resolved exactly'],
]) {
mode = scenario;
const before = admissions.length;
await button.focus();
await page.keyboard.press('Enter');
await expect.poll(() => admissions.length).toBe(before + 1);
expect(admissions.at(-1).status).toBe(status);
await expect(page.getByText(text, { exact: false }).first()).toBeVisible();
await page.screenshot({ path: new URL(`${scenario}-${width}.png`, output).pathname, fullPage: true });
}
mode = 'accepted';
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(button).toBeVisible();
await button.click();
await expect.poll(() => admissions.at(-1).mode).toBe('accepted');
await expect(page.getByText(`Patrol is investigating ${alert.resourceName}`, { exact: true })).toBeVisible();
results.push({ width, admissions });
await context.close();
}
expect(errors).toEqual([]);
const paths = ['internal/unifiedresources/history_identity.go', 'internal/ai/patrol_alert_scope.go', 'internal/ai/patrol_state.go', 'internal/ai/patrol_run.go', 'internal/ai/patrol_ai.go', 'internal/ai/patrol_triggers.go'];
const content_sha256 = Object.fromEntries(await Promise.all(paths.map(async (path) => [path, createHash('sha256').update(await readFile(new URL(`../${path}`, import.meta.url))).digest('hex')])));
await writeFile(new URL('result.json', output), JSON.stringify({ result: 'passed', verified_at: new Date().toISOString(), frontend_base_sha: process.env.PROOF_BASE_SHA, content_sha256, routes: ['/alerts'], viewports: [1440, 390], states: ['default', 'menu open/closed', 'accepted', 'busy', 'unresolved owner', 'reload'], interactions: ['pointer', 'Enter', 'Escape', 'outside click', 'reload then re-submit'], results, errors, limits: 'Real API admission, synthetic estate/session, no inference or diagnosis qualification' }, null, 2));
} catch (error) {
if (lastPage && !lastPage.isClosed()) {
await lastPage.screenshot({ path: new URL('failure.png', output).pathname, fullPage: true });
await writeFile(new URL('failure.txt', output), `${error.stack}\nPage errors: ${JSON.stringify(errors)}\nDiagnostics: ${JSON.stringify(diagnostics)}\nURL: ${lastPage.url()}\n${await lastPage.locator('body').innerText()}`);
}
throw error;
} finally {
await browser.close();
}