From ca9b659ddfaa82a2d0ec9a81323d2f45fddae81b Mon Sep 17 00:00:00 2001 From: "Ajnas ( Ajnas N B)" <114915484+AjnasNB@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:23:08 +0530 Subject: [PATCH 01/19] feat(runtime): enforce browser process resource budgets --- schemas/session.schema.json | 34 ++++- src/contracts.ts | 25 ++++ src/policy.ts | 10 ++ src/resource-usage.ts | 251 ++++++++++++++++++++++++++++++++ src/runtime.ts | 127 +++++++++++++++- test/platform-expansion.test.ts | 4 + test/policy.test.ts | 7 + test/resource-usage.test.ts | 125 ++++++++++++++++ 8 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 src/resource-usage.ts create mode 100644 test/resource-usage.test.ts diff --git a/schemas/session.schema.json b/schemas/session.schema.json index 5ceffaf..6a87910 100644 --- a/schemas/session.schema.json +++ b/schemas/session.schema.json @@ -196,13 +196,25 @@ "minimum": 1, "maximum": 64 }, + "maxProcessRssBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8589934592 + }, + "maxProcessCpuTimeMs": { + "type": "integer", + "minimum": 1, + "maximum": 604800000 + }, "maxDownloadBytes": { "type": "integer", - "minimum": 1 + "minimum": 1, + "maximum": 2147483648 }, "maxUploadBytes": { "type": "integer", - "minimum": 1 + "minimum": 1, + "maximum": 2147483648 }, "maxSnapshotChars": { "type": "integer", @@ -211,13 +223,29 @@ }, "maxEvidenceBytes": { "type": "integer", - "minimum": 1 + "minimum": 1, + "maximum": 10737418240 }, "maxHistoryEntries": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "maxNetworkEntries": { + "type": "integer", + "minimum": 1, + "maximum": 50000 + }, + "maxClipboardBytes": { + "type": "integer", + "minimum": 1, + "maximum": 1048576 + }, + "maxSavedStates": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, "maxNetworkRules": { "type": "integer", "minimum": 1, diff --git a/src/contracts.ts b/src/contracts.ts index 645a67d..2fd2635 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -77,6 +77,10 @@ export interface ResourceBudget { maxActions: number; maxDurationMs: number; maxTabs: number; + /** Aggregate resident memory allowed for a runtime-owned browser process tree. */ + maxProcessRssBytes: number; + /** Aggregate CPU time allowed for a runtime-owned browser process tree. */ + maxProcessCpuTimeMs: number; maxDownloadBytes: number; maxUploadBytes: number; maxSnapshotChars: number; @@ -94,6 +98,8 @@ export const DEFAULT_BUDGET: Readonly = Object.freeze({ maxActions: 250, maxDurationMs: 30 * 60_000, maxTabs: 8, + maxProcessRssBytes: 1024 * 1024 * 1024, + maxProcessCpuTimeMs: 60 * 60_000, maxDownloadBytes: 64 * 1024 * 1024, maxUploadBytes: 64 * 1024 * 1024, maxSnapshotChars: 120_000, @@ -480,6 +486,23 @@ export interface TabSummary { active: boolean; } +export type ResourceLimitState = "within" | "exceeded" | "unavailable"; + +export interface BrowserResourceUsage { + ownership: "runtime-owned" | "external"; + available: boolean; + sampledAt?: string; + processCount?: number; + rssBytes?: number; + peakRssBytes?: number; + cpuTimeMs?: number; + peakCpuTimeMs?: number; + limitState: ResourceLimitState; + maxProcessRssBytes: number; + maxProcessCpuTimeMs: number; + reason?: string; +} + export interface SessionSummary { id: string; state: SessionState; @@ -492,6 +515,7 @@ export interface SessionSummary { updatedAt: string; actionsUsed: number; budget: ResourceBudget; + resources: BrowserResourceUsage; tabs: TabSummary[]; challenge?: ChallengeReport; } @@ -536,6 +560,7 @@ export interface ContextRecorder { export const BROWSER_EVENT_TYPES = [ "browser.session.created", "browser.session.closed", + "browser.session.resource-limit-exceeded", "browser.action.started", "browser.action.completed", "browser.progress", diff --git a/src/policy.ts b/src/policy.ts index 584c2cc..1050795 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -133,6 +133,16 @@ export function clampBudget(input?: Partial): ResourceBudget { maxActions: positive(input?.maxActions, DEFAULT_BUDGET.maxActions, 10_000), maxDurationMs: positive(input?.maxDurationMs, DEFAULT_BUDGET.maxDurationMs, 24 * 60 * 60_000), maxTabs: positive(input?.maxTabs, DEFAULT_BUDGET.maxTabs, 64), + maxProcessRssBytes: positive( + input?.maxProcessRssBytes, + DEFAULT_BUDGET.maxProcessRssBytes, + 8 * 1024 ** 3 + ), + maxProcessCpuTimeMs: positive( + input?.maxProcessCpuTimeMs, + DEFAULT_BUDGET.maxProcessCpuTimeMs, + 7 * 24 * 60 * 60_000 + ), maxDownloadBytes: positive(input?.maxDownloadBytes, DEFAULT_BUDGET.maxDownloadBytes, 2 * 1024 ** 3), maxUploadBytes: positive(input?.maxUploadBytes, DEFAULT_BUDGET.maxUploadBytes, 2 * 1024 ** 3), maxSnapshotChars: positive(input?.maxSnapshotChars, DEFAULT_BUDGET.maxSnapshotChars, 2_000_000), diff --git a/src/resource-usage.ts b/src/resource-usage.ts new file mode 100644 index 0000000..f94a5b4 --- /dev/null +++ b/src/resource-usage.ts @@ -0,0 +1,251 @@ +import { execFile } from "node:child_process"; +import type { BrowserResourceUsage, ResourceBudget } from "./contracts.js"; + +export interface ProcessRecord { + pid: number; + parentPid: number; + rssBytes: number; + cpuTimeMs: number; +} + +export interface ProcessTreeSample { + sampledAt: string; + rootPid: number; + processCount: number; + rssBytes: number; + cpuTimeMs: number; +} + +export type ProcessTreeSampler = (rootPid: number) => Promise; + +const WINDOWS_PROCESS_SCRIPT = [ + "$ErrorActionPreference='Stop'", + "$items=Get-CimInstance Win32_Process | ForEach-Object {", + "[pscustomobject]@{pid=[int]$_.ProcessId;parentPid=[int]$_.ParentProcessId;rssBytes=[double]$_.WorkingSetSize;cpuTimeMs=([double]$_.KernelModeTime+[double]$_.UserModeTime)/10000}", + "}", + "$items | ConvertTo-Json -Compress" +].join("; "); + +export function aggregateProcessTree(rootPid: number, records: readonly ProcessRecord[]): ProcessTreeSample { + assertPid(rootPid); + const byParent = new Map(); + const byPid = new Map(); + for (const record of records) { + if (!validRecord(record)) continue; + byPid.set(record.pid, record); + const children = byParent.get(record.parentPid) ?? []; + children.push(record); + byParent.set(record.parentPid, children); + } + if (!byPid.has(rootPid)) throw new Error(`Browser process ${rootPid} is no longer running.`); + const selected: ProcessRecord[] = []; + const pending = [rootPid]; + const seen = new Set(); + while (pending.length > 0) { + const pid = pending.pop()!; + if (seen.has(pid)) continue; + seen.add(pid); + const record = byPid.get(pid); + if (record) selected.push(record); + for (const child of byParent.get(pid) ?? []) pending.push(child.pid); + } + return { + sampledAt: new Date().toISOString(), + rootPid, + processCount: selected.length, + rssBytes: Math.round(selected.reduce((total, record) => total + record.rssBytes, 0)), + cpuTimeMs: Math.round(selected.reduce((total, record) => total + record.cpuTimeMs, 0)) + }; +} + +export async function sampleProcessTree(rootPid: number): Promise { + assertPid(rootPid); + const records = process.platform === "win32" + ? await windowsProcessRecords() + : await posixProcessRecords(); + return aggregateProcessTree(rootPid, records); +} + +export class BrowserResourceTracker { + readonly rootPid: number; + readonly budget: Pick; + readonly sampleIntervalMs: number; + readonly sampler: ProcessTreeSampler; + #lastSampleAt = 0; + #usage: BrowserResourceUsage; + #sampleInFlight: Promise | undefined; + + constructor(input: { + rootPid: number; + budget: Pick; + sampleIntervalMs?: number; + sampler?: ProcessTreeSampler; + }) { + assertPid(input.rootPid); + this.rootPid = input.rootPid; + this.budget = { ...input.budget }; + this.sampleIntervalMs = boundedInterval(input.sampleIntervalMs ?? 2_000); + this.sampler = input.sampler ?? sampleProcessTree; + this.#usage = { + ownership: "runtime-owned", + available: false, + limitState: "unavailable", + maxProcessRssBytes: this.budget.maxProcessRssBytes, + maxProcessCpuTimeMs: this.budget.maxProcessCpuTimeMs, + reason: "No process-tree sample has been collected yet." + }; + } + + async sample(force = false): Promise { + const now = Date.now(); + if (!force && this.#lastSampleAt > 0 && now - this.#lastSampleAt < this.sampleIntervalMs) { + return structuredClone(this.#usage); + } + if (this.#sampleInFlight) return structuredClone(await this.#sampleInFlight); + const pending = this.#collect(now); + this.#sampleInFlight = pending; + try { + return structuredClone(await pending); + } finally { + this.#sampleInFlight = undefined; + } + } + + async #collect(startedAt: number): Promise { + try { + const sample = await this.sampler(this.rootPid); + const peakRssBytes = Math.max(this.#usage.peakRssBytes ?? 0, sample.rssBytes); + const peakCpuTimeMs = Math.max(this.#usage.peakCpuTimeMs ?? 0, sample.cpuTimeMs); + const exceeded = sample.rssBytes > this.budget.maxProcessRssBytes + || sample.cpuTimeMs > this.budget.maxProcessCpuTimeMs; + this.#usage = { + ownership: "runtime-owned", + available: true, + sampledAt: sample.sampledAt, + processCount: sample.processCount, + rssBytes: sample.rssBytes, + peakRssBytes, + cpuTimeMs: sample.cpuTimeMs, + peakCpuTimeMs, + limitState: exceeded ? "exceeded" : "within", + maxProcessRssBytes: this.budget.maxProcessRssBytes, + maxProcessCpuTimeMs: this.budget.maxProcessCpuTimeMs + }; + } catch (error) { + this.#usage = { + ...this.#usage, + available: false, + limitState: "unavailable", + maxProcessRssBytes: this.budget.maxProcessRssBytes, + maxProcessCpuTimeMs: this.budget.maxProcessCpuTimeMs, + reason: error instanceof Error ? error.message : String(error) + }; + } + this.#lastSampleAt = startedAt; + return this.#usage; + } + + current(): BrowserResourceUsage { + return structuredClone(this.#usage); + } +} + +export function unavailableResourceUsage( + budget: Pick, + reason: string, + ownership: BrowserResourceUsage["ownership"] = "external" +): BrowserResourceUsage { + return { + ownership, + available: false, + limitState: "unavailable", + maxProcessRssBytes: budget.maxProcessRssBytes, + maxProcessCpuTimeMs: budget.maxProcessCpuTimeMs, + reason + }; +} + +async function windowsProcessRecords(): Promise { + const output = await run("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + WINDOWS_PROCESS_SCRIPT + ]); + const parsed = JSON.parse(output) as unknown; + const rows = Array.isArray(parsed) ? parsed : [parsed]; + return rows.filter(isRecord).map((row) => ({ + pid: Number(row.pid), + parentPid: Number(row.parentPid), + rssBytes: Number(row.rssBytes), + cpuTimeMs: Number(row.cpuTimeMs) + })); +} + +async function posixProcessRecords(): Promise { + const output = await run("ps", ["-e", "-o", "pid=", "-o", "ppid=", "-o", "rss=", "-o", "time="]); + const records: ProcessRecord[] = []; + for (const line of output.split(/\r?\n/)) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d:-]+)$/); + if (!match) continue; + records.push({ + pid: Number(match[1]), + parentPid: Number(match[2]), + rssBytes: Number(match[3]) * 1024, + cpuTimeMs: parseCpuTime(match[4]!) + }); + } + return records; +} + +function parseCpuTime(value: string): number { + const [dayPart, clockPart] = value.includes("-") ? value.split("-", 2) : ["0", value]; + const units = (clockPart ?? "0").split(":").map(Number); + const seconds = units.length === 3 + ? units[0]! * 3600 + units[1]! * 60 + units[2]! + : units.length === 2 + ? units[0]! * 60 + units[1]! + : units[0] ?? 0; + return ((Number(dayPart) || 0) * 86_400 + seconds) * 1_000; +} + +function run(file: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile(file, args, { + encoding: "utf8", + windowsHide: true, + timeout: 10_000, + maxBuffer: 16 * 1024 * 1024 + }, (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }); + }); +} + +function validRecord(record: ProcessRecord): boolean { + return Number.isSafeInteger(record.pid) + && record.pid > 0 + && Number.isSafeInteger(record.parentPid) + && record.parentPid >= 0 + && Number.isFinite(record.rssBytes) + && record.rssBytes >= 0 + && Number.isFinite(record.cpuTimeMs) + && record.cpuTimeMs >= 0; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object"); +} + +function assertPid(pid: number): void { + if (!Number.isSafeInteger(pid) || pid <= 0) throw new TypeError("A positive browser process id is required."); +} + +function boundedInterval(value: number): number { + if (!Number.isSafeInteger(value) || value < 250 || value > 60_000) { + throw new TypeError("Resource sample intervals must be between 250 and 60000 milliseconds."); + } + return value; +} diff --git a/src/runtime.ts b/src/runtime.ts index f0ee5e6..ebe5bc0 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -9,6 +9,7 @@ import type { Browser, BrowserContext, BrowserContextOptions, + BrowserServer, BrowserType, ConsoleMessage, Dialog, @@ -32,6 +33,7 @@ import type { BrowserActivityQuery, BrowserLifecycleEvent, BrowserNetworkRecord, + BrowserResourceUsage, ChallengeReport, ContextRecorder, EvidenceRecord, @@ -72,6 +74,11 @@ import { } from "./policy.js"; import { ProfileVault } from "./profile-vault.js"; import { PersistentBrowserProfileStore } from "./persistent-profiles.js"; +import { + BrowserResourceTracker, + unavailableResourceUsage, + type ProcessTreeSampler +} from "./resource-usage.js"; import { captureSnapshot, locatorFor } from "./snapshot.js"; export interface SecretResolver { @@ -90,6 +97,10 @@ export interface BrowserRuntimeOptions { uploadRoots?: string[]; now?: () => Date; activity?: ActivityLedgerOptions; + /** Sampling cadence for runtime-owned browser process trees. */ + resourceSampleIntervalMs?: number; + /** Test or platform override for process-tree sampling. */ + processTreeSampler?: ProcessTreeSampler; } interface InternalTabLock extends TabLockSummary { @@ -107,6 +118,7 @@ interface ManagedSession { input: SessionCreateInput; id: string; browser?: Browser; + browserServer?: BrowserServer; context: BrowserContext; attached: boolean; state: SessionSummary["state"]; @@ -132,6 +144,10 @@ interface ManagedSession { routeHandler?: (route: Route) => Promise; pageHandler?: (page: Page) => void; persistentProfile?: string; + resources: BrowserResourceUsage; + resourceTracker?: BrowserResourceTracker; + resourceMonitor?: NodeJS.Timeout; + resourceLimitReported: boolean; } const CHALLENGE_SAFE_ACTIONS = new Set([ @@ -166,6 +182,8 @@ export class BrowserRuntime { readonly dnsResolver?: DnsResolver; readonly uploadRoots: readonly string[]; readonly activity: ActivityLedger; + readonly resourceSampleIntervalMs: number; + readonly processTreeSampler?: ProcessTreeSampler; #sessions = new Map(); #activePersistentProfiles = new Map(); #initialized = false; @@ -198,6 +216,21 @@ export class BrowserRuntime { (options.uploadRoots ?? [join(this.root, "uploads")]).map((entry) => resolve(entry)) ); this.activity = new ActivityLedger(options.activity); + if ( + options.resourceSampleIntervalMs !== undefined + && (!Number.isSafeInteger(options.resourceSampleIntervalMs) + || options.resourceSampleIntervalMs < 250 + || options.resourceSampleIntervalMs > 60_000) + ) { + throw new CockroachBrowserError( + "RESOURCE_SAMPLE_INTERVAL_INVALID", + "Resource sample intervals must be between 250 and 60000 milliseconds." + ); + } + // Windows process-tree enumeration is materially more expensive than ps. + // Keep the monitor sparse there; action boundaries reuse a fresh cached sample. + this.resourceSampleIntervalMs = options.resourceSampleIntervalMs ?? (process.platform === "win32" ? 10_000 : 5_000); + if (options.processTreeSampler) this.processTreeSampler = options.processTreeSampler; } async initialize(): Promise { @@ -275,6 +308,7 @@ export class BrowserRuntime { if (provider.persistentProfile) this.#activePersistentProfiles.set(provider.persistentProfile, id); const createdAt = nowIso(); let browser: Browser | undefined; + let browserServer: BrowserServer | undefined; let context: BrowserContext | undefined; let attached = false; let managedSession: ManagedSession | undefined; @@ -370,7 +404,8 @@ export class BrowserRuntime { }); browser = context.browser() ?? undefined; } else { - browser = await browserType.launch(launchOptions); + browserServer = await browserType.launchServer(launchOptions); + browser = await browserType.connect(browserServer.wsEndpoint()); context = await browser.newContext(contextOptions); } // Headers may contain bearer credentials. The browser context has its @@ -378,10 +413,29 @@ export class BrowserRuntime { delete input.extraHTTPHeaders; } + const browserPid = browserServer?.process()?.pid; + const resourceTracker = browserPid + ? new BrowserResourceTracker({ + rootPid: browserPid, + budget: policy.budget, + sampleIntervalMs: this.resourceSampleIntervalMs, + ...(this.processTreeSampler ? { sampler: this.processTreeSampler } : {}) + }) + : undefined; + const resources = resourceTracker + ? resourceTracker.current() + : unavailableResourceUsage( + policy.budget, + attached + ? "The runtime is attached to a customer-owned CDP browser and cannot account for or terminate its complete process tree." + : "This runtime-owned persistent browser does not expose a portable process handle through Playwright.", + attached ? "external" : "runtime-owned" + ); const session: ManagedSession = { input, id, ...(browser ? { browser } : {}), + ...(browserServer ? { browserServer } : {}), context, attached, state: "starting", @@ -403,6 +457,9 @@ export class BrowserRuntime { dnsPins, consumedApprovals: new Set(), tabLocks: new Map(), + resources, + ...(resourceTracker ? { resourceTracker } : {}), + resourceLimitReported: false, ...(provider.persistentProfile ? { persistentProfile: provider.persistentProfile } : {}) }; managedSession = session; @@ -412,6 +469,14 @@ export class BrowserRuntime { this.#registerPage(session, page); if (startUrl) await page.goto(startUrl.toString(), { waitUntil: "domcontentloaded" }); await this.#recordHistory(session, page, "session.start"); + if (session.resourceTracker) { + session.resources = await session.resourceTracker.sample(true); + this.#throwIfResourceLimitExceeded(session); + session.resourceMonitor = setInterval(() => { + void this.#monitorSessionResources(session); + }, this.resourceSampleIntervalMs); + session.resourceMonitor.unref(); + } session.state = "ready"; session.updatedAt = nowIso(); await this.#recordContext(session, "browser.session.created", { @@ -434,6 +499,7 @@ export class BrowserRuntime { } if (context && !attached) await context.close().catch(() => undefined); if (browser && !attached) await browser.close().catch(() => undefined); + if (browserServer) await browserServer.close().catch(() => undefined); this.#sessions.delete(id); if (provider.persistentProfile) this.#activePersistentProfiles.delete(provider.persistentProfile); throw error; @@ -442,6 +508,7 @@ export class BrowserRuntime { async session(id: string): Promise { const session = this.#requireSession(id); + session.resources = await this.#sampleSessionResources(session); const tabs = await Promise.all( [...session.tabs.entries()].map(async ([tabId, page]) => ({ id: tabId, @@ -462,6 +529,7 @@ export class BrowserRuntime { updatedAt: session.updatedAt, actionsUsed: session.actionsUsed, budget: { ...session.budget }, + resources: structuredClone(session.resources), tabs, ...(session.challenge ? { challenge: structuredClone(session.challenge) } : {}) }; @@ -471,6 +539,14 @@ export class BrowserRuntime { return Promise.all([...this.#sessions.keys()].map((id) => this.session(id))); } + async resourceUsage(id: string): Promise { + const session = this.#requireSession(id); + if (!session.resourceLimitReported) { + session.resources = await this.#sampleSessionResources(session, true); + } + return structuredClone(session.resources); + } + activities(query: BrowserActivityQuery = {}): BrowserLifecycleEvent[] { return this.activity.list(query); } @@ -530,6 +606,8 @@ export class BrowserRuntime { async closeSession(id: string, options: { saveProfile?: boolean; passphrase?: string } = {}): Promise { const session = this.#requireSession(id); + if (session.resourceMonitor) clearInterval(session.resourceMonitor); + session.resources = await this.#sampleSessionResources(session, true); if (options.saveProfile && session.input.profile) { const passphrase = options.passphrase; if (!passphrase) { @@ -582,13 +660,16 @@ export class BrowserRuntime { } } if (!session.attached && session.browser) await session.browser.close(); + if (session.browserServer) await session.browserServer.close().catch(() => undefined); if (session.persistentProfile) this.#activePersistentProfiles.delete(session.persistentProfile); session.state = "closed"; session.updatedAt = nowIso(); await this.#recordContext(session, "browser.session.closed", {}); await this.#publishEvent(session, "browser.session.closed", { actionsUsed: session.actionsUsed, - evidenceBytes: session.evidenceBytes + evidenceBytes: session.evidenceBytes, + peakRssBytes: session.resources.peakRssBytes ?? null, + peakCpuTimeMs: session.resources.peakCpuTimeMs ?? null }); this.#sessions.delete(id); } @@ -679,7 +760,7 @@ export class BrowserRuntime { }); try { - this.#assertSessionBudget(session); + await this.#assertSessionBudget(session); session.actionsUsed += 1; session.updatedAt = nowIso(); if (!action.purpose?.trim() || action.purpose.trim().length > 500) { @@ -2405,13 +2486,51 @@ export class BrowserRuntime { return page; } - #assertSessionBudget(session: ManagedSession): void { + async #assertSessionBudget(session: ManagedSession): Promise { if (session.actionsUsed >= session.budget.maxActions) { throw new CockroachBrowserError("ACTION_BUDGET_EXCEEDED", "The session action limit has been reached."); } if (Date.now() - Date.parse(session.createdAt) > session.budget.maxDurationMs) { throw new CockroachBrowserError("DURATION_BUDGET_EXCEEDED", "The session duration limit has been reached."); } + session.resources = await this.#sampleSessionResources(session); + this.#throwIfResourceLimitExceeded(session); + } + + async #sampleSessionResources(session: ManagedSession, force = false): Promise { + if (!session.resourceTracker) return structuredClone(session.resources); + return session.resourceTracker.sample(force); + } + + #throwIfResourceLimitExceeded(session: ManagedSession): void { + if (session.resources.limitState !== "exceeded") return; + if ((session.resources.rssBytes ?? 0) > session.budget.maxProcessRssBytes) { + throw new CockroachBrowserError( + "PROCESS_RSS_BUDGET_EXCEEDED", + `The browser process tree uses ${session.resources.rssBytes} bytes, above its ${session.budget.maxProcessRssBytes}-byte limit.` + ); + } + throw new CockroachBrowserError( + "PROCESS_CPU_BUDGET_EXCEEDED", + `The browser process tree has used ${session.resources.cpuTimeMs} ms of CPU time, above its ${session.budget.maxProcessCpuTimeMs}-ms limit.` + ); + } + + async #monitorSessionResources(session: ManagedSession): Promise { + if (session.state === "closed" || session.resourceLimitReported) return; + session.resources = await this.#sampleSessionResources(session, true); + if (session.resources.limitState !== "exceeded") return; + session.resourceLimitReported = true; + session.state = "failed"; + session.updatedAt = nowIso(); + if (session.resourceMonitor) clearInterval(session.resourceMonitor); + await this.#publishEvent(session, "browser.session.resource-limit-exceeded", { + rssBytes: session.resources.rssBytes ?? null, + cpuTimeMs: session.resources.cpuTimeMs ?? null, + maxProcessRssBytes: session.budget.maxProcessRssBytes, + maxProcessCpuTimeMs: session.budget.maxProcessCpuTimeMs + }); + await session.browserServer?.close().catch(() => undefined); } async #assertPageAdmitted(session: ManagedSession, page: Page): Promise { diff --git a/test/platform-expansion.test.ts b/test/platform-expansion.test.ts index c4f73b2..ff6d0a0 100644 --- a/test/platform-expansion.test.ts +++ b/test/platform-expansion.test.ts @@ -62,6 +62,10 @@ test( }); assert.equal(session.engine, engine); assert.equal(session.tabs.length, 1); + assert.equal(session.resources.available, true); + assert.equal(session.resources.ownership, "runtime-owned"); + assert.equal((session.resources.processCount ?? 0) > 0, true); + assert.equal((session.resources.rssBytes ?? 0) > 0, true); const snapshot = await runtime.snapshot(session.id); assert.equal(snapshot.title, "Three engine fixture"); assert.match(snapshot.text, /ready/); diff --git a/test/policy.test.ts b/test/policy.test.ts index 5221fe6..f732e31 100644 --- a/test/policy.test.ts +++ b/test/policy.test.ts @@ -29,6 +29,8 @@ test("normalizes origins and applies finite resource ceilings", () => { assert.deepEqual(normalized.allowedOrigins, ["https://example.com"]); assert.equal(normalized.budget.maxActions, 10_000); assert.equal(normalized.budget.maxTabs, 1); + assert.equal(normalized.budget.maxProcessRssBytes, 1024 * 1024 * 1024); + assert.equal(normalized.budget.maxProcessCpuTimeMs, 60 * 60_000); assert.equal(normalized.budget.maxNetworkEntries, 2_000); assert.equal(normalized.budget.maxClipboardBytes, 64 * 1024); assert.equal(normalized.budget.maxSavedStates, 64); @@ -40,6 +42,11 @@ test("normalizes origins and applies finite resource ceilings", () => { () => clampBudget({ maxDurationMs: 0 }), (error: unknown) => hasCode(error, "INVALID_BUDGET") ); + assert.equal(clampBudget({ maxProcessRssBytes: 99 * 1024 ** 3 }).maxProcessRssBytes, 8 * 1024 ** 3); + assert.throws( + () => clampBudget({ maxProcessCpuTimeMs: Number.NaN }), + (error: unknown) => hasCode(error, "INVALID_BUDGET") + ); }); test("requires an explicit HTTP or HTTPS origin allowlist", () => { diff --git a/test/resource-usage.test.ts b/test/resource-usage.test.ts new file mode 100644 index 0000000..b97a9b0 --- /dev/null +++ b/test/resource-usage.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { BrowserResourceTracker, aggregateProcessTree, unavailableResourceUsage } from "../src/resource-usage.js"; + +test("aggregates only the owned process tree", () => { + const sample = aggregateProcessTree(10, [ + { pid: 10, parentPid: 1, rssBytes: 100, cpuTimeMs: 5 }, + { pid: 11, parentPid: 10, rssBytes: 200, cpuTimeMs: 6 }, + { pid: 12, parentPid: 11, rssBytes: 300, cpuTimeMs: 7 }, + { pid: 20, parentPid: 1, rssBytes: 10_000, cpuTimeMs: 500 } + ]); + + assert.equal(sample.rootPid, 10); + assert.equal(sample.processCount, 3); + assert.equal(sample.rssBytes, 600); + assert.equal(sample.cpuTimeMs, 18); +}); + +test("tracks peaks, caches samples, and reports resource limit breaches", async () => { + let calls = 0; + const tracker = new BrowserResourceTracker({ + rootPid: 42, + budget: { maxProcessRssBytes: 500, maxProcessCpuTimeMs: 100 }, + sampleIntervalMs: 1_000, + sampler: async (rootPid) => { + calls += 1; + return { + rootPid, + sampledAt: `2026-09-02T00:00:0${calls}.000Z`, + processCount: 2, + rssBytes: calls === 1 ? 400 : 600, + cpuTimeMs: calls === 1 ? 20 : 40 + }; + } + }); + + const first = await tracker.sample(); + const cached = await tracker.sample(); + const exceeded = await tracker.sample(true); + + assert.equal(calls, 2); + assert.equal(first.limitState, "within"); + assert.deepEqual(cached, first); + assert.equal(exceeded.limitState, "exceeded"); + assert.equal(exceeded.peakRssBytes, 600); + assert.equal(exceeded.peakCpuTimeMs, 40); +}); + +test("coalesces concurrent operating-system samples", async () => { + let calls = 0; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const tracker = new BrowserResourceTracker({ + rootPid: 42, + budget: { maxProcessRssBytes: 500, maxProcessCpuTimeMs: 100 }, + sampler: async (rootPid) => { + calls += 1; + await gate; + return { + rootPid, + sampledAt: "2026-09-02T00:00:00.000Z", + processCount: 1, + rssBytes: 50, + cpuTimeMs: 10 + }; + } + }); + + const first = tracker.sample(true); + const second = tracker.sample(true); + release(); + assert.deepEqual(await first, await second); + assert.equal(calls, 1); +}); + +test("resource sampling failures remain explicit instead of becoming zero usage", async () => { + const tracker = new BrowserResourceTracker({ + rootPid: 42, + budget: { maxProcessRssBytes: 500, maxProcessCpuTimeMs: 100 }, + sampler: async () => { + throw new Error("process exited"); + } + }); + + const usage = await tracker.sample(true); + assert.equal(usage.available, false); + assert.equal(usage.limitState, "unavailable"); + assert.match(usage.reason ?? "", /process exited/); + + const external = unavailableResourceUsage( + { maxProcessRssBytes: 500, maxProcessCpuTimeMs: 100 }, + "customer owned" + ); + assert.equal(external.ownership, "external"); + assert.equal(external.rssBytes, undefined); +}); + +test("rejects missing process roots and unsafe sample intervals", () => { + assert.throws(() => aggregateProcessTree(9, []), /no longer running/); + assert.throws( + () => new BrowserResourceTracker({ + rootPid: 42, + budget: { maxProcessRssBytes: 500, maxProcessCpuTimeMs: 100 }, + sampleIntervalMs: 100 + }), + /between 250 and 60000/ + ); +}); + +test("the public session schema exposes every configurable resource ceiling", async () => { + const schema = JSON.parse(await readFile("schemas/session.schema.json", "utf8")) as { + $defs: { policy: { properties: { budget: { properties: Record } } } }; + }; + const properties = schema.$defs.policy.properties.budget.properties; + for (const name of [ + "maxProcessRssBytes", + "maxProcessCpuTimeMs", + "maxNetworkEntries", + "maxClipboardBytes", + "maxSavedStates" + ]) { + assert.ok(properties[name], `${name} must be represented in the public session schema`); + } +}); From 29cc512ea525837c13a6e5747b2d1520353059db Mon Sep 17 00:00:00 2001 From: "Ajnas ( Ajnas N B)" <114915484+AjnasNB@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:24:36 +0530 Subject: [PATCH 02/19] feat(runtime): add cross-engine lean browsing profile --- schemas/session.schema.json | 6 +++++ src/contracts.ts | 4 +++ src/runtime.ts | 24 ++++++++++++++++- test/platform-expansion.test.ts | 47 +++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/schemas/session.schema.json b/schemas/session.schema.json index 6a87910..f7a9944 100644 --- a/schemas/session.schema.json +++ b/schemas/session.schema.json @@ -37,6 +37,12 @@ "webkit" ] }, + "performanceProfile": { + "enum": [ + "balanced", + "lean" + ] + }, "startUrl": { "type": "string", "format": "uri", diff --git a/src/contracts.ts b/src/contracts.ts index 2fd2635..348c496 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -71,6 +71,7 @@ export type RiskLevel = "low" | "medium" | "high" | "critical"; export type Effect = "read" | "write" | "execute" | "upload" | "download" | "credential"; export type BrowserMode = "headless" | "headed"; export type BrowserEngine = "chromium" | "firefox" | "webkit"; +export type BrowserPerformanceProfile = "balanced" | "lean"; export type SessionState = "starting" | "ready" | "challenge" | "closed" | "failed"; export interface ResourceBudget { @@ -157,6 +158,8 @@ export interface SessionCreateInput { mode?: BrowserMode; /** Installed Playwright engine used by the bounded runtime. Defaults to Chromium. */ engine?: BrowserEngine; + /** Balanced preserves page assets; lean blocks images, media, fonts, and service workers. */ + performanceProfile?: BrowserPerformanceProfile; startUrl?: string; locale?: string; timezoneId?: string; @@ -509,6 +512,7 @@ export interface SessionSummary { profile?: string; mode: BrowserMode; engine: BrowserEngine; + performanceProfile: BrowserPerformanceProfile; purpose: string; actor?: string; createdAt: string; diff --git a/src/runtime.ts b/src/runtime.ts index ebe5bc0..31f9e35 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -248,6 +248,7 @@ export class BrowserRuntime { const input = structuredClone(rawInput); const engine = input.engine ?? "chromium"; input.engine = engine; + input.performanceProfile = input.performanceProfile ?? "balanced"; const browserType = browserTypeFor(engine); const policy = normalizePolicy(input.policy); input.policy = policy; @@ -352,11 +353,23 @@ export class BrowserRuntime { const extensionArgs = provider.extensions.length ? [`--disable-extensions-except=${provider.extensions.join(",")}`, `--load-extension=${provider.extensions.join(",")}`] : []; + const performanceArgs = input.performanceProfile === "lean" && engine === "chromium" + ? [ + "--disable-background-networking", + "--disable-component-update", + "--disable-default-apps", + "--disable-sync", + "--metrics-recording-only", + "--no-first-run" + ] + : []; const launchOptions: NonNullable[0]> = { headless: (input.mode ?? "headless") === "headless", ...(provider.executablePath ? { executablePath: provider.executablePath } : {}), ...(proxy ? { proxy } : {}), - ...(provider.arguments.length || extensionArgs.length ? { args: [...provider.arguments, ...extensionArgs] } : {}) + ...(provider.arguments.length || extensionArgs.length || performanceArgs.length + ? { args: [...provider.arguments, ...extensionArgs, ...performanceArgs] } + : {}) }; let storageState: Record | undefined; if (input.profile) { @@ -376,6 +389,7 @@ export class BrowserRuntime { await mkdir(sessionArtifactRoot, { recursive: true }); const contextOptions: BrowserContextOptions = { acceptDownloads: Boolean(policy.allowDownloads), + serviceWorkers: input.performanceProfile === "lean" ? "block" : "allow", locale: input.locale ?? "en-US", ...(input.timezoneId ? { timezoneId: input.timezoneId } : {}), ...(input.colorScheme ? { colorScheme: input.colorScheme } : {}), @@ -523,6 +537,7 @@ export class BrowserRuntime { ...(session.input.profile ? { profile: session.input.profile } : {}), mode: session.input.mode ?? "headless", engine: session.input.engine ?? "chromium", + performanceProfile: session.input.performanceProfile ?? "balanced", purpose: session.input.purpose, ...(session.input.actor ? { actor: session.input.actor } : {}), createdAt: session.createdAt, @@ -2370,6 +2385,13 @@ export class BrowserRuntime { }); return; } + if ( + session.input.performanceProfile === "lean" + && ["image", "media", "font"].includes(request.resourceType()) + ) { + await route.abort("blockedbyclient"); + return; + } await route.continue(); } catch { await route.abort("blockedbyclient").catch(() => undefined); diff --git a/test/platform-expansion.test.ts b/test/platform-expansion.test.ts index ff6d0a0..31b9b21 100644 --- a/test/platform-expansion.test.ts +++ b/test/platform-expansion.test.ts @@ -77,6 +77,53 @@ test( } ); +test( + "lean runtime blocks heavy page assets across Chromium, Firefox, and WebKit", + { skip: process.env.COCKROACH_BROWSER_MULTI_ENGINE_E2E !== "1", timeout: 120_000 }, + async (t) => { + let imageRequests = 0; + const fixture = createServer((request, response) => { + if (request.url === "/large.png") { + imageRequests += 1; + response.writeHead(200, { "content-type": "image/png" }); + response.end(Buffer.alloc(64 * 1024)); + return; + } + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("Lean fixtureready"); + }); + await new Promise((resolve) => fixture.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve, reject) => fixture.close((error) => error ? reject(error) : resolve()))); + const address = fixture.address(); + assert(address && typeof address === "object"); + const origin = `http://127.0.0.1:${address.port}`; + for (const engine of RAW_BROWSER_ENGINES) { + const root = await mkdtemp(join(tmpdir(), `cockroach-browser-lean-${engine}-`)); + const runtime = new BrowserRuntime({ root }); + try { + const session = await runtime.createSession({ + engine, + performanceProfile: "lean", + startUrl: origin, + purpose: `Verify lean ${engine}`, + policy: { + allowedOrigins: [origin], + allowPrivateNetwork: true, + allowedActions: ["snapshot"] + } + }); + assert.equal(session.performanceProfile, "lean"); + const snapshot = await runtime.snapshot(session.id); + assert.match(snapshot.text, /ready/); + } finally { + await runtime.close(); + await rm(root, { recursive: true, force: true }); + } + } + assert.equal(imageRequests, 0); + } +); + test( "bounded runtime launches headed Chromium, Firefox, and WebKit", { skip: process.env.COCKROACH_BROWSER_HEADED_E2E !== "1", timeout: 120_000 }, From 091285e7f6e407f3b1bbcdd93720f9710fe9bcbc Mon Sep 17 00:00:00 2001 From: "Ajnas ( Ajnas N B)" <114915484+AjnasNB@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:27:07 +0530 Subject: [PATCH 03/19] feat(observability): expose browser resource telemetry --- dashboard/app.js | 42 +++++++++++++++--- dashboard/styles.css | 13 ++++++ src/cli.ts | 2 + src/client.ts | 5 +++ src/server.ts | 29 ++++++++++++- test/dashboard.test.ts | 77 +++++++++++++++++++++++++++++++++ test/server.test.ts | 98 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 test/dashboard.test.ts diff --git a/dashboard/app.js b/dashboard/app.js index 5490bbe..6001c7d 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -147,8 +147,13 @@ function render() { return; } + const sampledResources = state.sessions.filter((session) => session.resources?.available); + const totalRssBytes = sampledResources.reduce((total, session) => total + (session.resources.rssBytes ?? 0), 0); + const totalCpuTimeMs = sampledResources.reduce((total, session) => total + (session.resources.cpuTimeMs ?? 0), 0); panels.innerHTML = ` - ${metricPanel("Active sessions", state.sessions.length, "Authorized Chromium contexts currently owned by this worker.")} + ${metricPanel("Active sessions", state.sessions.length, "Authorized Chromium, Firefox, and WebKit sessions currently owned by this worker.")} + ${metricPanel("Browser RSS", sampledResources.length ? formatBytes(totalRssBytes) : "—", `${sampledResources.length} of ${state.sessions.length} session process trees measured.`)} + ${metricPanel("Browser CPU", sampledResources.length ? formatDurationMs(totalCpuTimeMs) : "—", "Cumulative CPU time for currently measured browser process trees.")} ${metricPanel("Evidence records", state.evidence.length, "Snapshots, artifacts, audits, comparisons, and action records.")} ${metricPanel("Persistent profiles", state.profiles.length, "Explicit runtime-owned profiles; ambient personal profiles remain outside the daemon.")} ${metricPanel("Receipt chain", state.verify?.ok ? "OK" : state.health ? "CHECK" : "WAIT", "Integrity is verified against the local evidence ledger.")} @@ -190,13 +195,31 @@ function widePanel(label, body) { function listSessions(sessions, emptyMessage) { if (!sessions.length) return `
${escapeHtml(emptyMessage)}
`; return `
    ${sessions.map((session) => ` -
  • - ${escapeHtml(session.purpose ?? "Untitled session")}
    ${escapeHtml(session.id ?? "unknown")}
    - ${escapeHtml(session.challenge?.kind ?? `${session.state ?? "unknown"} / ${session.mode ?? "unknown"}`)} +
  • +
    + ${escapeHtml(session.purpose ?? "Untitled session")}
    ${escapeHtml(session.id ?? "unknown")}
    + ${escapeHtml(session.challenge?.kind ?? `${session.state ?? "unknown"} / ${session.mode ?? "unknown"} / ${session.engine ?? "unknown"}`)} +
    + ${renderSessionResources(session.resources)}
  • `).join("")}
`; } +function renderSessionResources(resources) { + if (!resources || !resources.available) { + const reason = resources?.reason ?? "This daemon did not provide an owned process-tree sample."; + return `
Resource sample unavailable${escapeHtml(reason)}
`; + } + const stateValue = resources.limitState === "exceeded" ? "exceeded" : "within"; + return `
+
RSS
${escapeHtml(formatBytes(resources.rssBytes))} / ${escapeHtml(formatBytes(resources.maxProcessRssBytes))}
+
CPU time
${escapeHtml(formatDurationMs(resources.cpuTimeMs))} / ${escapeHtml(formatDurationMs(resources.maxProcessCpuTimeMs))}
+
Processes
${escapeHtml(resources.processCount ?? "—")}
+
Limit
${escapeHtml(stateValue)}
+ Sampled ${escapeHtml(resources.sampledAt ?? "now")} +
`; +} + function listEvidence(records, emptyMessage) { if (!records.length) return `
${escapeHtml(emptyMessage)}
`; return `