Files
dsh-browser-install/index.js
T
Plugin Dev d2e2eaed8d dsh-browser-install: auto-install Playwright Chromium and bridge it for dsh-ego-browser
On load, ensures the Playwright browser target (default: chromium) is installed
via `npx --yes playwright install chromium --with-deps` (background when missing,
so profile startup is not blocked), then symlinks the installed binary at
/usr/local/bin/google-chrome-stable (+ chromium). That exact name is what
dsh-ego-browser's PATH scan and its bundled --no-sandbox wrapper both look for,
so root/Docker containers get a working agent browser with zero manual PATH,
env-var or wrapper steps. Pre-existing user binaries/wrappers are never
clobbered. Ships as a dsh bundle (plain ESM, no build step); install via
`dsh plugin --profile web add github:<you>/dsh-browser-install#main` and update
with `dsh plugin --profile web update` (branch ref resolves the tip).
2026-10-01 08:36:01 +01:00

353 lines
13 KiB
JavaScript

// @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-<rev>/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')
}
}