From d2e2eaed8d37625a8f0028c311ac909c432f13b3 Mon Sep 17 00:00:00 2001 From: Plugin Dev Date: Thu, 1 Oct 2026 08:36:01 +0100 Subject: [PATCH] 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:/dsh-browser-install#main` and update with `dsh plugin --profile web update` (branch ref resolves the tip). --- .gitignore | 4 + README.md | 122 ++++++++++++++++ cordis.patch.yml | 6 + index.js | 353 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 35 +++++ 5 files changed, 520 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cordis.patch.yml create mode 100644 index.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8e7368 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +pnpm-lock.yaml +*.log +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..55cd0c5 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# dsh-browser-install + +Automatically installs **Playwright Chromium** (`npx playwright install chromium --with-deps`) into the Docker container running DeepSeek Harness, and bridges the installed binary onto `PATH` so the **dsh-ego-browser** side-bar plugin finds it — no manual "find the binary / symlink it / set `EGO_LINUX_*` env vars / wrap it with `--no-sandbox`" steps for agents ever again. + +## Why this plugin exists + +`dsh-ego-browser` discovers its browser the following way (see `lib/index.js` → `resolveEgoEnv` and `runtime/ego-linux/src/chrome.mjs`): + +1. It scans `PATH` + fixed locations for `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser`, … and +2. on **root** hosts (docker default), it hands the found binary to a bundled `--no-sandbox` wrapper that only looks at fixed paths (`/usr/bin/*`, `/opt/google/...`, `/usr/local/bin/google-chrome-stable`, `/snap/bin/chromium`). + +Playwright installs Chromium into `~/.cache/ms-playwright/chromium-/chrome-linux{,64}/chrome` — a location **invisible to both scans**. That is why agents kept having to hunt the binary down, symlink/wrap it, and set `EGO_LINUX_CHROME` / `EGO_LINUX_EXTRA_ARGS` by hand. + +This plugin does exactly that bridging once, automatically: + +- on load it checks the Playwright cache (idempotent, no-op when the browser is already there), +- if the browser is missing it runs `npx --yes playwright install chromium --with-deps` **in the background** (profile startup is never blocked for minutes), and +- it then symlinks the installed binary at `/usr/local/bin/google-chrome-stable` and `/usr/local/bin/chromium` — the exact names ego-browser's PATH scan *and* its bundled `--no-sandbox` wrapper look for. On this root container, the chain then resolves to: root detected → bundled `--no-sandbox` wrapper → `--no-sandbox` launch; no `DISPLAY` → automatic headless. **No env vars, no manual wrapper, no agent steps.** + +Nothing is ever clobbered: a pre-existing system Chrome or a user wrapper keeps winning (the plugin only creates links where nothing of its own/an external binary exists). + +## Install (from git — recommended) + +Replace `` with your GitHub account name: + +```sh +dsh plugin --profile web add github:/dsh-browser-install#main +``` + +- The `#main` **branch ref is what makes updates work**: `pnpm update` re-resolves the branch tip (verified against pnpm 12.8.1), so after you push new commits simply run + + ```sh + dsh plugin --profile web update # all deps + dsh plugin --profile web update dsh-browser-install # just this one + ``` + +- Bundle changes only become active after a profile restart: `dsh --profile web`. +- No build step: the bundle ships plain ESM and declares no `prepare` script, so pnpm's default block on git-dependency build scripts never applies — nothing to `allowBuilds` in `pnpm-workspace.yaml`. +- pnpm installs git deps over HTTPS, so keep the repo public (or set up credentials on the host). + +## Alternative: install from a local checkout + +```sh +dsh plugin --profile web add /root/workspaces/Plugin\ Development/dsh-browser-install +``` + +A directory install is a symlinked `file:` dependency — handy while iterating. For persistent installs and updates, prefer the git flow above. + +## Restart and verify + +Restart the profile, then check the plugin loaded and did its job: + +```sh +dsh --profile web +``` + +```sh +dsh --profile web --dump-config | grep -A3 browser-install +ls -la /usr/local/bin/google-chrome-stable # -> .../ms-playwright/chromium-*/chrome-linux64/chrome +``` + +Then from any agent session, `ego_doctor` should report a browser binary, and `ego_space_open`/`ego_navigate` work with no environment prep. + +> Non-root hosts: set `symlinkDir` to a directory on your `PATH` (e.g. `~/.local/bin`). On root only `/usr/local/bin` is needed — it also satisfies the bundled `--no-sandbox` wrapper's absolute-path list. + +## Config + +All fields are optional. Override them in your profile's `cordis.patch.yml` (or in the bundle row's `config`): + +```yaml +- id: browser-install + name: "dsh-browser-install" + config: + withDeps: true # run `--with-deps` (apt system deps); needs root/apt + target: chromium # chromium | chromium-headless-shell | chrome + package: playwright # npx package spec, e.g. "playwright@1.54.1" + forceInstall: false # always re-run the install on load + installOnStartup: true # install in background when the browser is missing + cacheDir: "" # empty = $PLAYWRIGHT_BROWSERS_PATH | ~/.cache/ms-playwright + symlinkDir: /usr/local/bin + symlinkNames: [google-chrome-stable, chromium] + installTimeoutMs: 600000 +``` + +| Field | Default | Meaning | +|---|---|---| +| `installOnStartup` | `true` | Run the Playwright install when the plugin loads **and** no matching browser is found. The install runs in the background so profile startup is not blocked. | +| `forceInstall` | `false` | Always run the install on load, even when a browser is already present (e.g. after a Playwright upgrade). | +| `package` | `playwright` | Package spec passed to `npx --yes install ...`. Pin with e.g. `playwright@1.54.1`. | +| `target` | `chromium` | Which Playwright browser to ensure: `chromium` (full build), `chromium-headless-shell`, or `chrome` (Google Chrome). Default matches what ego-browser expects. | +| `withDeps` | `true` | Pass `--with-deps` so Playwright also installs the browser's system dependencies via apt (requires root; the DSH image runs as root). | +| `cacheDir` | auto | Playwright browsers cache root. Empty = `$PLAYWRIGHT_BROWSERS_PATH`, else `~/.cache/ms-playwright`. When set here, the installer child also gets `PLAYWRIGHT_BROWSERS_PATH`. | +| `symlinkDir` | `/usr/local/bin` | Directory for the PATH links; must be on the DSH process `PATH`. | +| `symlinkNames` | `[google-chrome-stable, chromium]` | Link names to create. `google-chrome-stable` is the one ego-browser's PATH scan and its `--no-sandbox` wrapper both check. | +| `installTimeoutMs` | `600000` | Max time for the background install before it is killed. | + +## Behaviour notes + +- **No-clobber links:** a link whose target lives inside the Playwright cache is treated as ours and refreshed to the newest installed revision; any other existing file/symlink is left untouched (and wins). +- **Lifecycle:** the background installer child is killed if the plugin is unloaded mid-install (`ctx.effect`). The PATH links are intentionally *not* removed on unload — a running browser may be using them and they are harmless, idempotent shared state. +- **Error visibility:** install failures, symlink errors, and a `symlinkDir` missing from `PATH` are all logged loudly under the `dsh-browser-install` logger scope, so an agent can read the reason instead of guessing. +- **ego-browser settings untouched:** the plugin does not modify dsh-ego-browser's config or any `EGO_LINUX_*` env vars — it only fills the gap (install + visible symlink) that makes ego-browser's existing auto-adaptation work (root → `--no-sandbox` wrapper, no DISPLAY → headless). + +## Uninstall + +```sh +dsh plugin --profile web remove dsh-browser-install +``` + +The symlinks are left in place (harmless; the browser keeps working for ego-browser). Remove them manually if you truly want them gone: + +```sh +rm -f /usr/local/bin/google-chrome-stable /usr/local/bin/chromium +``` + +## Development + +Pure helpers (`resolveCacheDir`, `resolveBrowserBinary`, `planSymlinks`, `applySymlinkPlan`, `buildInstallCmd`) are exported for testing; the plugin itself needs no build step (plain ESM, no TypeScript compile). + +- Node ≥ 22 (the harness image ships v24). +- Requires DSH ≥ 0.1.2-rc.1 (same baseline as dsh-ego-browser). +- Runtime dependency: `@deepseek-ai/schemastery` (already present in the profile). \ No newline at end of file diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..cf38697 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,6 @@ +# dsh-browser-install — ensure Playwright Chromium is installed and reachable +# by dsh-ego-browser in root / Docker containers. See README.md for details and +# the full config table; every field is optional (schema defaults apply). +- insert: + - id: browser-install + name: "dsh-browser-install" \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..06cf6be --- /dev/null +++ b/index.js @@ -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-/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') + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6cc3502 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "dsh-browser-install", + "version": "0.1.0", + "description": "Auto-installs Playwright Chromium (npx playwright install chromium --with-deps) into the DSH container and bridges it onto PATH so dsh-ego-browser finds it without manual env-var or wrapper steps.", + "type": "module", + "main": "index.js", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.3" + }, + "dsh": { + "engines": { + "dsh": ">=0.1.2-rc.1" + }, + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "files": [ + "index.js", + "cordis.patch.yml", + "README.md" + ], + "keywords": [ + "dsh", + "dsh-plugin", + "playwright", + "chromium", + "ego-browser", + "browser" + ] +} \ No newline at end of file