/** * Browser pin-drift check (managed-browser-runtime D9). * * A module bundles its own `playwright-core`, and celilo provisions the * browser binary. Under D2 the consumer passes an explicit * `executablePath`, so a client bump can no longer move the browser out * from under it — the hard failure that started that change cannot recur. * What remains is softer: a client several minor versions from the binary's * build drives it over a protocol the two were versioned together. Usually * fine; not guaranteed. * * So this WARNS and never blocks (D9 / task 4.2). * * **It compares REVISIONS, not version strings.** The revision is what * actually has to match a build, and each `playwright-core` states its own * in the `browsers.json` inside the package — which is where 1193 and 1223 * came from. Reading it out of the bundle is what keeps this honest and * means nobody maintains a version→revision table that will rot. * * Like `system doctor` (D4), it reads the bundle and the descriptor and * never asks Playwright which executable it would use: that reports the * full browser regardless of what a headless launch would open. */ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { resolveBrowser } from '@celilo/capabilities'; import type { ModuleManifest } from '../../manifest/schema'; import type { DriftFinding } from './types'; /** What a module bundles, already read off disk. */ export interface BundledBrowserClient { moduleId: string; /** The bundled `playwright-core` version, e.g. `1.60.0`. */ clientVersion: string; /** The chromium-headless-shell revision that client expects, e.g. `1223`. */ expectedRevision: string; } export interface BrowserPinAuditDeps { /** * Modules that bundle a browser client. A module with none contributes * nothing — most modules never launch a browser. */ consumers: BundledBrowserClient[]; /** * The provisioned browser, or `null` when this host has none. * * Null means silence, not a finding: installation is opt-in, so a host * without a browser is the normal case and `system doctor` is already * the place that reports its absence. */ provisioned: { revision: string; playwrightVersion: string } | null; } export async function auditBrowserPin(deps: BrowserPinAuditDeps): Promise { const { provisioned } = deps; if (!provisioned) return []; const findings: DriftFinding[] = []; for (const consumer of deps.consumers) { if (consumer.expectedRevision === provisioned.revision) continue; // Any difference is reported. A "how far apart is too far" threshold // would be a tuning knob with no evidence behind it — the revision is // the thing that has to match a build, so it either does or it does not. findings.push({ category: 'browser_pin', severity: 'drift', code: 'browser_client_revision_drift', subject: consumer.moduleId, message: `${consumer.moduleId} bundles playwright-core ${consumer.clientVersion}, which expects ` + `chromium revision ${consumer.expectedRevision}, but this host has revision ` + `${provisioned.revision} (installed for playwright ${provisioned.playwrightVersion}).`, details: 'Not a failure: the module passes an explicit executablePath, so it launches the ' + 'installed browser regardless of which revision its client expects. The risk is ' + 'protocol skew — a client far from its browser build drives it over a protocol the ' + 'two were versioned together.', remediation: `Align them: either republish ${consumer.moduleId} against playwright-core ` + `${provisioned.playwrightVersion}, or set celilo-mgmt's playwright_version to ` + `${consumer.clientVersion} and redeploy it.`, actionable: false, }); } return findings; } /** * Read the browser client a module bundles, or `null` when it bundles none. * * Searches beside each hook script rather than walking the module tree: * `scripts/node_modules` is where a module's hook runtime is bundled, and * the manifest's own hook list is the authoritative statement of where its * hook code lives. A module laying its hooks out differently * (`celilo/scripts/…`, as the one real browser consumer does) is therefore * found without hardcoding either layout. */ export function readBundledBrowserClient( moduleId: string, sourcePath: string, hookScripts: string[], ): BundledBrowserClient | null { const searched = new Set([join(sourcePath, 'scripts')]); for (const script of hookScripts) { searched.add(dirname(join(sourcePath, script))); } for (const dir of searched) { const pkgRoot = join(dir, 'node_modules', 'playwright-core'); const browsers = join(pkgRoot, 'browsers.json'); if (!existsSync(browsers)) continue; const expectedRevision = revisionFor(browsers, 'chromium-headless-shell'); if (!expectedRevision) continue; return { moduleId, clientVersion: packageVersion(join(pkgRoot, 'package.json')) ?? 'unknown', expectedRevision, }; } return null; } /** * The revision a `playwright-core` states for a browser, from the * `browsers.json` inside the package. Never a lookup table — the package * is the only thing that knows, and a table would rot silently. */ function revisionFor(browsersJsonPath: string, browserName: string): string | null { try { const parsed = JSON.parse(readFileSync(browsersJsonPath, 'utf-8')) as { browsers?: Array<{ name?: string; revision?: string | number }>; }; const entry = parsed.browsers?.find((b) => b.name === browserName); return entry?.revision === undefined ? null : String(entry.revision); } catch { return null; } } function packageVersion(packageJsonPath: string): string | null { try { const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: string }; return parsed.version ?? null; } catch { return null; } } /** * Gather this check's inputs from the module store and the provisioned * browser's descriptor. * * Lives here rather than in the CLI so the two `AuditDeps` builders stay * one line each and cannot drift apart — they already each carry their own * copy of every other category's wiring. */ export function collectBrowserPinDeps( modules: Array<{ id: string; sourcePath: string; manifestData: unknown }>, ): BrowserPinAuditDeps { const consumers: BundledBrowserClient[] = []; for (const module of modules) { const manifest = module.manifestData as ModuleManifest | undefined; const hookScripts = Object.values(manifest?.hooks ?? {}) .map((hook) => (hook as { script?: string } | undefined)?.script) .filter((script): script is string => typeof script === 'string'); const bundled = readBundledBrowserClient(module.id, module.sourcePath, hookScripts); if (bundled) consumers.push(bundled); } let provisioned: BrowserPinAuditDeps['provisioned'] = null; try { const browser = resolveBrowser(); provisioned = { revision: browser.revision, playwrightVersion: browser.playwrightVersion }; } catch { // No browser on this host — the check stays silent. `system doctor` // is where an absent browser is reported, not here. } return { consumers, provisioned }; }