// @ts-check /** * dsh-browser-install — make a Playwright Chromium available to dsh-ego-browser * in root / Docker containers, with zero manual PATH / env-var / wrapper steps. * * Why this plugin exists * ---------------------- * dsh-ego-browser discovers its browser binary by scanning PATH + fixed * locations (google-chrome, chromium, ...) and, on root hosts, hands the found * binary to a bundled `--no-sandbox` wrapper. Playwright installs Chromium * into `~/.cache/ms-playwright/chromium-/chrome-linux{,64}/chrome`, which * is invisible to both scans — so agents had to hunt the binary down, symlink * or wrap it, and set EGO_LINUX_* env vars by hand. * * This plugin does that once, automatically, on load: * 1. ensures `npx playwright install chromium --with-deps` has been run * (background when the browser is missing, so profile startup is not * blocked), and * 2. symlinks the installed binary at `/usr/local/bin/google-chrome-stable` * (+ `chromium`), which is exactly where ego-browser's PATH scan and its * bundled --no-sandbox wrapper both look. * * Nothing is ever clobbered: pre-existing non-Playwright binaries (system * Chrome, hand-made wrappers) are left untouched and keep winning. * * Revertible-effect discipline: the background installer child is tracked with * ctx.effect so it is killed if the plugin is unloaded mid-install. The PATH * symlinks are deliberately NOT removed on unload — a running browser may be * using them, and they are harmless shared state (idempotent on next load). */ import { spawn } from 'node:child_process' import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, symlinkSync, unlinkSync, } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import z from '@deepseek-ai/schemastery' export const name = 'dsh-browser-install' // ── Config ─────────────────────────────────────────────────────────────────── export const Config = z.object({ installOnStartup: z .boolean() .description('Run the Playwright browser install when the plugin loads and no matching browser is found.') .default(true), forceInstall: z .boolean() .description('Always run `npx playwright install ...` on load, even when a browser binary is already present.') .default(false), package: z .string() .description('Package spec passed to npx, e.g. "playwright" or "playwright@1.54.1".') .default('playwright'), target: z .union(['chromium', 'chromium-headless-shell', 'chrome']) .description('Playwright install target to ensure. "chromium" is the full build dsh-ego-browser expects.') .default('chromium'), withDeps: z .boolean() .description('Pass --with-deps so Playwright also installs the browser system dependencies (apt).') .default(true), cacheDir: z .string() .description('Playwright browsers cache root. Empty = $PLAYWRIGHT_BROWSERS_PATH or ~/.cache/ms-playwright.') .default(''), symlinkDir: z .string() .description('Directory for the PATH links (must be on PATH; /usr/local/bin works in the DSH docker image).') .default('/usr/local/bin'), symlinkNames: z .array(z.string()) .description('Link names so dsh-ego-browser and CLI tools can find the browser.') .default(['google-chrome-stable', 'chromium']), installTimeoutMs: z .number() .description('Max time allowed for the background install step before it is killed.') .default(600000), }) // ── Pure helpers (exported for testing) ────────────────────────────────────── /** Where Playwright keeps its browsers. */ export function resolveCacheDir(config, env = process.env) { if (config.cacheDir) return config.cacheDir if (env.PLAYWRIGHT_BROWSERS_PATH) return env.PLAYWRIGHT_BROWSERS_PATH return join(env.HOME || homedir(), '.cache', 'ms-playwright') } const TARGET_LAYOUT = { chromium: { prefix: 'chromium-', names: ['chrome-linux/chrome', 'chrome-linux64/chrome'], }, chrome: { prefix: 'chrome-', names: ['chrome-linux/chrome', 'chrome-linux64/chrome'], }, 'chromium-headless-shell': { prefix: 'chromium_headless_shell-', names: ['chrome-linux/headless_shell', 'chrome-linux64/headless_shell'], }, } /** * Locate the newest installed Playwright browser binary for `target`. * Returns an absolute path, or null when no revision is installed. */ export function resolveBrowserBinary({ target, cacheDir }) { const layout = TARGET_LAYOUT[target] if (!layout) throw new Error(`unknown browser target: ${target}`) if (!existsSync(cacheDir)) return null let best = null for (const entry of readdirSync(cacheDir)) { if (!entry.startsWith(layout.prefix)) continue const rev = Number.parseInt(entry.slice(layout.prefix.length), 10) if (!Number.isFinite(rev)) continue for (const rel of layout.names) { const abs = join(cacheDir, entry, rel) if (existsSync(abs)) { if (!best || rev > best.rev) best = { rev, path: abs } break } } } return best?.path ?? null } /** * Decide what to do with each link path, without touching the filesystem. * * action: * - 'create' -> nothing at the link path, safe to create * - 'refresh' -> our own previous link (target lives inside the Playwright * cache) pointing at a stale revision: recreate to `binary` * - 'keep' -> a user-managed file/symlink (or other): leave it alone * - 'error' -> lstat failed for a reason other than ENOENT */ export function planSymlinks({ binary, symlinkDir, symlinkNames, cacheDir }) { return symlinkNames.map((name) => { const link = join(symlinkDir, name) let action try { const st = lstatSync(link) if (st.isSymbolicLink()) { const target = readlinkSync(link) action = target === binary ? 'ok' : target.startsWith(cacheDir) ? 'refresh' : 'keep' } else { action = 'keep' } } catch (error) { action = error?.code === 'ENOENT' ? 'create' : 'error' } return { name, link, action, binary } }) } /** Execute a symlink plan. Returns the links it (re)created. */ export function applySymlinkPlan(plan) { const created = [] for (const item of plan) { if (item.action !== 'create' && item.action !== 'refresh') continue mkdirSync(dirname(item.link), { recursive: true }) try { unlinkSync(item.link) } catch { // nothing at the path yet — fine } symlinkSync(item.binary, item.link, 'file') created.push(item.link) } return created } /** The npx command this plugin runs. */ export function buildInstallCmd(config) { const args = [ '--yes', config.package, 'install', ...(config.withDeps ? ['--with-deps'] : []), config.target, ] return { cmd: 'npx', args } } // ── Runtime wiring ─────────────────────────────────────────────────────────── function makeLogger(ctx) { const log = ctx.logger?.(name) return log ?? { debug() {}, info() {}, warn() {}, error() {} } } /** * Spawn the Playwright installer, stream its output to the logger, and return * a handle bound to the plugin lifetime (ctx.effect kills the child on * unload). Never throws synchronously; failures surface through promise. */ function runInstall(ctx, config, cacheDir, log) { const { cmd, args } = buildInstallCmd(config) const child = spawn(cmd, args, { env: { ...process.env, // Make the installer write where this plugin will look for the binary. ...(config.cacheDir ? { PLAYWRIGHT_BROWSERS_PATH: cacheDir } : {}), }, stdio: ['ignore', 'pipe', 'pipe'], }) const timer = setTimeout(() => { log.warn(`install exceeded ${config.installTimeoutMs}ms; killing it`) try { child.kill('SIGTERM') } catch { // already gone } }, config.installTimeoutMs) const cleanup = () => { clearTimeout(timer) try { child.kill('SIGTERM') } catch { // already gone } } ctx.effect(() => cleanup) const promise = new Promise((resolve, reject) => { let stdout = '' let stderr = '' const cap = 60000 // keep only the tail for error reporting child.stdout.on('data', (chunk) => { const text = chunk.toString() stdout = (stdout + text).slice(-cap) for (const line of text.split('\n')) { const trimmed = line.trimEnd() if (trimmed) log.debug(trimmed) } }) child.stderr.on('data', (chunk) => { const text = chunk.toString() stderr = (stderr + text).slice(-cap) for (const line of text.split('\n')) { const trimmed = line.trimEnd() if (trimmed) log.warn(trimmed) } }) child.on('error', (error) => reject(error)) child.on('close', (code) => { clearTimeout(timer) if (code === 0) { resolve({ stdout, stderr }) } else { const tail = [stdout, stderr].filter(Boolean).join('\n').trimEnd().slice(-2000) reject(new Error(`npx playwright install exited with code ${code}${tail ? `\n--- tail ---\n${tail}` : ''}`)) } }) }) log.info(`installing: ${cmd} ${args.join(' ')}`) return { child, promise, cleanup } } function ensureLinks(binary, cacheDir, config, log) { const plan = planSymlinks({ binary, symlinkDir: config.symlinkDir, symlinkNames: config.symlinkNames, cacheDir }) let created = [] try { created = applySymlinkPlan(plan) } catch (error) { log.error(`symlink creation failed: ${error?.message ?? error}`) return { created: [], errors: [String(error?.message ?? error)] } } const kept = plan.filter((item) => item.action === 'keep').map((item) => item.link) if (kept.length) log.info(`kept existing (user-managed): ${kept.join(', ')}`) for (const link of created) log.info(`linked ${link} -> ${binary}`) const pathDirs = (process.env.PATH ?? '') .split(':') .filter(Boolean) .map((dir) => dir.replace(/\/+$/, '')) if (!pathDirs.includes(config.symlinkDir.replace(/\/+$/, ''))) { log.warn( `symlinkDir "${config.symlinkDir}" is not on PATH — dsh-ego-browser's binary scan and the --no-sandbox wrapper may miss it. ` + `Add it to PATH or set symlinkDir to a directory already on PATH (e.g. /usr/local/bin).`, ) } return { created, errors: [] } } /** * One-shot ensure: resolve the binary, install in the background if missing * (or if forceInstall), then (re)link. Returns a status string for apply(). */ async function ensureBrowser(ctx, config, log) { const target = config.target const cacheDir = resolveCacheDir(config) log.info(`target=${target} cacheDir=${cacheDir}`) let binary = resolveBrowserBinary({ target, cacheDir }) if (binary) log.info(`found browser at ${binary}`) if (!binary && !config.installOnStartup) { log.warn( `no ${target} installed at ${cacheDir} and installOnStartup=false — ` + `run \`npx playwright install ${config.withDeps ? '--with-deps ' : ''}${target}\` manually, or enable installOnStartup.`, ) return { status: 'missing', cacheDir } } const needInstall = !binary || config.forceInstall if (!needInstall) { ensureLinks(binary, cacheDir, config, log) return { status: 'ready', binary, cacheDir } } // Background install so profile startup is never blocked for minutes. const { promise } = runInstall(ctx, config, cacheDir, log) void promise.then( async () => { const installed = resolveBrowserBinary({ target, cacheDir }) if (!installed) { log.error('install reported success but no browser binary was found in cacheDir') return } log.info(`install finished; browser at ${installed}`) ensureLinks(installed, cacheDir, config, log) }, (error) => { log.error(`install failed: ${error?.message ?? error}`) }, ) return { status: 'installing', cacheDir } } export async function apply(ctx, config) { const log = makeLogger(ctx) log.info('mounting: ensure a Playwright browser is installed and discoverable by dsh-ego-browser') const outcome = await ensureBrowser(ctx, config, log) if (outcome.status === 'ready') { log.info(`ready: dsh-ego-browser will find "${outcome.binary}" on the next ego_* call (root -> --no-sandbox wrapper, no DISPLAY -> headless)`) } else if (outcome.status === 'installing') { log.info('browser missing — install is running in the background; ego_* browser launches will fail until it completes') } }