add gstack 2 parity and lifecycle gates

This commit is contained in:
Sinabina
2026-07-17 11:08:32 -07:00
parent b6572ebbb7
commit 9919c4cdd3
212 changed files with 29098 additions and 3845 deletions
+190 -285
View File
@@ -1,25 +1,16 @@
/**
* Sidepanel DOM test — verifies the extension's sidepanel.html/.js/.css
* actually render and react to security events correctly when loaded in
* a real Chromium.
* Real-Chromium regression coverage for the sidepanel's current security UI.
*
* Uses Playwright + BrowserManager. The extension sidepanel is loaded via
* file:// with a stubbed window.fetch that simulates the browse server
* returning /health + /sidebar-chat responses. We inject security_event
* entries via the stubbed /sidebar-chat response and assert:
* The classifier-backed chat queue was removed when the primary surface
* became a terminal PTY. Until classifier status is wired to that surface,
* the honest contract is deliberately negative:
*
* * Banner renders (display: block, not display: none)
* * Title + subtitle text reflects domain + layer
* * Layer scores appear in the expandable details
* * Shield icon data-status attr flips based on /health.security.status
* * Escape key dismisses the banner
* * Expand button toggles aria-expanded + layer list visibility
* - /health.security.status must not light the hidden SEC shield.
* - retired /sidebar-chat security_event data must not render a banner or
* leak attacker-controlled text into the terminal surface.
*
* All 83 prior security tests cover the JS behavior in isolation; this
* test covers the integration: sidepanel.html + sidepanel.js + sidepanel.css
* + real DOM + real event dispatch.
*
* Runs in ~2s. Gate tier. Skipped if Playwright isn't available.
* Every HTTP, SSE, WebSocket, and beacon primitive is replaced before the
* sidepanel scripts load, so this test never reaches a real browse server.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
@@ -30,41 +21,36 @@ import { chromium, type Browser, type Page } from 'playwright';
const EXTENSION_DIR = path.resolve(import.meta.dir, '..', '..', 'extension');
const SIDEPANEL_URL = `file://${EXTENSION_DIR}/sidepanel.html`;
/**
* Eager check — does Playwright have chromium installed on disk?
* test.skipIf() is evaluated at file-registration time (before beforeAll),
* so a runtime probe of `browser` state wouldn't work — all tests would
* unconditionally get registered as `skip: true`. We need a sync check.
*/
const CHROMIUM_AVAILABLE = (() => {
try {
const exe = chromium.executablePath();
return !!exe && fs.existsSync(exe);
const executable = chromium.executablePath();
return Boolean(executable && fs.existsSync(executable));
} catch {
return false;
}
})();
/**
* Seed the sidepanel so it thinks it's connected + poll-ready before
* sidepanel.js runs its connection flow. We stub chrome.runtime, chrome.tabs,
* and window.fetch so the sidepanel code paths behave as if a real browse
* server is responding.
*/
async function installStubsBeforeLoad(page: Page, scenario: {
healthSecurity?: { status: 'protected' | 'degraded' | 'inactive'; layers?: any };
securityEntries?: any[];
}): Promise<void> {
await page.addInitScript((params: any) => {
// Stub chrome.runtime for the background-service-worker connection flow.
// sendMessage supports both callback and Promise style — sidepanel.js
// uses both patterns depending on the call site.
type Scenario = {
healthSecurity: {
status: 'protected' | 'degraded' | 'inactive';
layers?: Record<string, string>;
};
securityEntries?: unknown[];
};
async function installStubsBeforeLoad(page: Page, scenario: Scenario): Promise<void> {
await page.addInitScript((params: Scenario) => {
const requests: Array<{ url: string; method: string }> = [];
(window as any).__gstackTestRequests = requests;
(window as any).chrome = {
runtime: {
sendMessage: (_req: any, cb: any) => {
sendMessage: (_request: unknown, callback?: (value: unknown) => void) => {
// Omit a token so sidepanel.js exercises the direct /health
// bootstrap path whose security payload is under test.
const payload = { connected: true, port: 34567 };
if (typeof cb === 'function') {
setTimeout(() => cb(payload), 0);
if (typeof callback === 'function') {
setTimeout(() => callback(payload), 0);
return undefined;
}
return Promise.resolve(payload);
@@ -73,288 +59,207 @@ async function installStubsBeforeLoad(page: Page, scenario: {
onMessage: { addListener: () => {} },
},
tabs: {
query: (_q: any, cb: any) => setTimeout(() => cb([{ id: 1, url: 'https://example.com' }]), 0),
query: (_query: unknown, callback: (tabs: unknown[]) => void) =>
setTimeout(() => callback([{ id: 1, url: 'https://example.com' }]), 0),
onActivated: { addListener: () => {} },
onUpdated: { addListener: () => {} },
},
};
// Stub EventSource — connectSSE() throws without this because file://
// can't actually open an SSE connection to http://127.0.0.1.
(window as any).EventSource = class {
constructor() {}
(window as any).EventSource = class StubEventSource {
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;
readyState = 1;
constructor(url: string) {
requests.push({ url: String(url), method: 'EVENTSOURCE' });
}
addEventListener() {}
close() {}
close() { this.readyState = 2; }
};
// Stub fetch.
const scenarioRef = params;
const origFetch = window.fetch;
window.fetch = async function (input: any, init?: any) {
(window as any).WebSocket = class StubWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = 0;
constructor(url: string) {
requests.push({ url: String(url), method: 'WEBSOCKET' });
}
addEventListener() {}
send() {}
close() { this.readyState = 3; }
};
Object.defineProperty(navigator, 'sendBeacon', {
configurable: true,
value: (url: string) => {
requests.push({ url: String(url), method: 'BEACON' });
return true;
},
});
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
requests.push({ url, method: init?.method ?? 'GET' });
if (url.endsWith('/health')) {
return new Response(JSON.stringify({
status: 'healthy',
token: 'test-token',
AUTH_TOKEN: 'test-token',
mode: 'headed',
agent: { status: 'idle', runningFor: null, queueLength: 0 },
session: null,
security: scenarioRef.healthSecurity ?? { status: 'degraded', layers: {}, lastUpdated: '' },
security: params.healthSecurity,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.endsWith('/sse-session')) {
return new Response(null, { status: 204 });
}
if (url.endsWith('/memory')) {
return new Response(JSON.stringify({ bunServer: { rss: 0 }, tabs: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.endsWith('/pty-session')) {
// Keep the terminal bootstrap deterministic and prevent a WebSocket
// attempt; this test concerns the pre-session terminal surface.
return new Response('terminal disabled in DOM test', { status: 503 });
}
if (url.includes('/sidebar-chat')) {
return new Response(JSON.stringify({
entries: scenarioRef.securityEntries ?? [],
total: (scenarioRef.securityEntries ?? []).length,
entries: params.securityEntries ?? [],
total: (params.securityEntries ?? []).length,
agentStatus: 'idle',
activeTabId: 1,
security: scenarioRef.healthSecurity ?? { status: 'degraded', layers: {} },
security: params.healthSecurity,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.includes('/sidebar-tabs')) {
return new Response(JSON.stringify({ tabs: [] }), { status: 200 });
if (url.endsWith('/refs')) {
return new Response(JSON.stringify({ refs: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/sidebar-activity')) {
return new Response('{}', { status: 200 });
}
// Fall through for anything else we didn't scenario.
if (typeof origFetch === 'function') return origFetch(input, init);
return new Response('{}', { status: 200 });
} as any;
// Fail closed inside the stub rather than falling through to the real
// network. Recording the URL above keeps unexpected bootstrap calls
// diagnosable in assertion output.
return new Response(JSON.stringify({ error: 'unstubbed test endpoint' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
};
}, scenario);
}
async function openStubbedSidepanel(
scenario: Scenario,
assertion: (page: Page) => Promise<void>,
): Promise<void> {
const context = await browser!.newContext();
try {
const page = await context.newPage();
await installStubsBeforeLoad(page, scenario);
await page.goto(SIDEPANEL_URL);
await page.waitForFunction(() =>
(window as any).gstackAuthToken === 'test-token' &&
document.getElementById('footer-dot')?.classList.contains('connected'),
);
await assertion(page);
} finally {
await context.close();
}
}
let browser: Browser | null = null;
beforeAll(async () => {
if (!CHROMIUM_AVAILABLE) return;
browser = await chromium.launch({ headless: true });
}, 30000);
}, 30_000);
afterAll(async () => {
if (browser) {
try { await browser.close(); } catch {}
}
if (!browser) return;
try {
await browser.close();
} catch {}
browser = null;
});
describe('sidepanel security DOM', () => {
test.skipIf(!CHROMIUM_AVAILABLE)('shield icon reflects /health.security.status', async () => {
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: {
status: 'protected',
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
},
});
await page.goto(SIDEPANEL_URL);
// sidepanel.js updates the shield after the first /health call
// succeeds. Give it a tick.
await page.waitForFunction(
() => document.getElementById('security-shield')?.getAttribute('data-status') === 'protected',
{ timeout: 5000 },
);
const status = await page.$eval('#security-shield', (el) => el.getAttribute('data-status'));
expect(status).toBe('protected');
// aria-label carries human-readable state
const aria = await page.$eval('#security-shield', (el) => el.getAttribute('aria-label'));
expect(aria).toContain('protected');
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)(
'protected health metadata does not expose an unwired SEC claim',
async () => {
await openStubbedSidepanel({
healthSecurity: {
status: 'protected',
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
},
}, async (page) => {
const shield = page.locator('#security-shield');
expect(await shield.count()).toBe(1);
expect(await shield.isVisible()).toBe(false);
expect(await shield.getAttribute('data-status')).toBeNull();
expect(await shield.getAttribute('aria-label')).toBe('Security status: unknown');
test.skipIf(!CHROMIUM_AVAILABLE)('shield flips to degraded when classifier warmup is incomplete', async () => {
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: {
status: 'degraded',
layers: { testsavant: 'off', transcript: 'ok', canary: 'ok' },
},
});
await page.goto(SIDEPANEL_URL);
await page.waitForFunction(
() => document.getElementById('security-shield')?.getAttribute('data-status') === 'degraded',
{ timeout: 5000 },
);
const status = await page.$eval('#security-shield', (el) => el.getAttribute('data-status'));
expect(status).toBe('degraded');
await context.close();
}, 15000);
const visibleText = await page.locator('body').innerText();
expect(visibleText).not.toContain('SEC');
expect(visibleText.toLowerCase()).not.toContain('protected');
test.skipIf(!CHROMIUM_AVAILABLE)('security_event entry triggers banner render with domain + layer scores', async () => {
const securityEntry = {
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'canary_leaked',
layer: 'canary',
confidence: 1.0,
domain: 'attacker.example.com',
channel: 'tool_use:Bash',
signals: [
{ layer: 'testsavant_content', confidence: 0.92 },
{ layer: 'transcript_classifier', confidence: 0.78 },
],
};
const requests = await page.evaluate(() => (window as any).__gstackTestRequests);
expect(requests.some((request: { url: string }) => request.url.endsWith('/health'))).toBe(true);
expect(requests.some((request: { url: string }) => request.url.endsWith('/sse-session'))).toBe(true);
});
},
15_000,
);
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: {
status: 'protected',
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
},
securityEntries: [securityEntry],
});
await page.goto(SIDEPANEL_URL);
test.skipIf(!CHROMIUM_AVAILABLE)(
'retired security_event data is neither polled nor rendered into the terminal',
async () => {
const attackerMarker = 'ATTACKER-CONTROLLED-TERMINAL-MARKER';
const attackerDomain = 'retired-chat.attacker.example';
await openStubbedSidepanel({
healthSecurity: {
status: 'protected',
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
},
securityEntries: [{
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: attackerMarker,
layer: 'canary',
confidence: 1,
domain: attackerDomain,
}],
}, async (page) => {
// Let immediate connection work and the first memory poll settle;
// neither may reintroduce the retired chat polling path.
await page.waitForTimeout(650);
// The banner should become visible once /sidebar-chat poll delivers the
// security_event entry and addChatEntry routes it to showSecurityBanner.
await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 });
const displayed = await page.$eval('#security-banner', (el) =>
window.getComputedStyle(el).display !== 'none',
);
expect(displayed).toBe(true);
const requests = await page.evaluate(() => (window as any).__gstackTestRequests);
expect(requests.some((request: { url: string }) => request.url.includes('/sidebar-chat'))).toBe(false);
expect(requests.some((request: { url: string }) => request.url.endsWith('/memory'))).toBe(true);
expect(requests.some((request: { url: string }) => request.url.startsWith('https://'))).toBe(false);
// Subtitle includes the attack domain
const subtitleText = await page.textContent('#security-banner-subtitle');
expect(subtitleText).toContain('attacker.example.com');
expect(subtitleText).toContain('prompt injection detected');
// Layer list was populated — primary layer (canary) always renders;
// signals array brings in the additional ML layers
const layers = await page.$$eval('.security-banner-layer', (els) =>
els.map((el) => el.textContent),
);
expect(layers.length).toBeGreaterThanOrEqual(1);
// Canary row expected
expect(layers.join(' ')).toMatch(/Canary|canary/);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('expand button toggles aria-expanded + reveals details', async () => {
const entry = {
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'ensemble_agreement',
layer: 'testsavant_content',
confidence: 0.88,
domain: 'example.com',
signals: [
{ layer: 'testsavant_content', confidence: 0.88 },
{ layer: 'transcript_classifier', confidence: 0.71 },
],
};
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } },
securityEntries: [entry],
});
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 });
// Initially collapsed
const initialAria = await page.$eval('#security-banner-expand', (el) =>
el.getAttribute('aria-expanded'),
);
expect(initialAria).toBe('false');
const initialHidden = await page.$eval('#security-banner-details', (el) =>
(el as HTMLElement).hidden,
);
expect(initialHidden).toBe(true);
// Click expand
await page.click('#security-banner-expand');
const expandedAria = await page.$eval('#security-banner-expand', (el) =>
el.getAttribute('aria-expanded'),
);
expect(expandedAria).toBe('true');
const expandedHidden = await page.$eval('#security-banner-details', (el) =>
(el as HTMLElement).hidden,
);
expect(expandedHidden).toBe(false);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('Escape key dismisses an open banner', async () => {
const entry = {
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'canary_leaked',
layer: 'canary',
confidence: 1.0,
domain: 'evil.example.com',
};
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } },
securityEntries: [entry],
});
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 });
// Hit Escape — should hide the banner
await page.keyboard.press('Escape');
// Wait a tick for the event handler to run
await page.waitForFunction(
() => {
const el = document.getElementById('security-banner');
return el ? window.getComputedStyle(el).display === 'none' : false;
},
{ timeout: 2000 },
);
const stillVisible = await page.$eval('#security-banner', (el) =>
window.getComputedStyle(el).display !== 'none',
);
expect(stillVisible).toBe(false);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('close button dismisses banner', async () => {
const entry = {
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'canary_leaked',
layer: 'canary',
confidence: 1.0,
domain: 'evil.example.com',
};
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } },
securityEntries: [entry],
});
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 });
await page.click('#security-banner-close');
await page.waitForFunction(
() => {
const el = document.getElementById('security-banner');
return el ? window.getComputedStyle(el).display === 'none' : false;
},
{ timeout: 2000 },
);
const displayed = await page.$eval('#security-banner', (el) =>
window.getComputedStyle(el).display !== 'none',
);
expect(displayed).toBe(false);
await context.close();
}, 15000);
expect(await page.locator('#security-banner').count()).toBe(0);
expect(await page.locator('.security-banner').count()).toBe(0);
const terminalText = await page.locator('#tab-terminal').innerText();
expect(terminalText).not.toContain(attackerMarker);
expect(terminalText).not.toContain(attackerDomain);
expect(await page.locator('#security-shield').isVisible()).toBe(false);
});
},
15_000,
);
});