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).
This commit is contained in:
commit
d2e2eaed8d
5 files changed
+520
No files matched your search
@@ -0,0 +1,353 @@
|
||||
// @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')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user