feat(browser): auto-detect NixOS and use system Chromium (#550)

Puppeteer's bundled Chromium is a dynamically-linked FHS binary that
cannot run on NixOS. On startup, resolveSystemChromium() checks for
/etc/NIXOS and searches for a usable binary in order:

  1. chromium on PATH
  2. chromium-browser on PATH
  3. ~/.nix-profile/bin/chromium
  4. /run/current-system/sw/bin/chromium

The resolved path is passed as executablePath to puppeteer.launch().
Result is cached per process. On non-NixOS systems the function returns
undefined immediately, leaving Puppeteer's default resolution intact.
This commit is contained in:
elikoga
2026-03-27 12:10:27 +01:00
committed by GitHub
parent c2040b8f86
commit d1b6ef08a1
2 changed files with 54 additions and 2 deletions
+1
View File
@@ -280,6 +280,7 @@ Headless browser automation with 14 stealth scripts to evade bot detection:
- **Selector flexibility**: CSS, `aria/`, `text/`, `xpath/`, `pierce/` query handlers for Shadow DOM piercing
- **Reader mode**: `extract_readable` action uses Mozilla Readability for clean article extraction
- **Headless/visible toggle**: Switch modes at runtime via `/browser` command or `browser.headless` setting
- **NixOS support**: Automatically detects NixOS (`/etc/NIXOS`) and resolves a system Chromium (`chromium` on PATH, `~/.nix-profile/bin/chromium`, or `/run/current-system/sw/bin/chromium`) since Puppeteer's bundled binary cannot run on a non-FHS system
### + Cursor Provider
+53 -2
View File
@@ -1,4 +1,4 @@
import * as fs from "node:fs/promises";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Readability } from "@mozilla/readability";
@@ -61,6 +61,56 @@ async function loadPuppeteer(): Promise<typeof Puppeteer> {
}
}
/**
* On NixOS, Puppeteer's bundled Chromium is a dynamically-linked FHS binary and
* cannot run as-is. Detect the platform and resolve a system-installed Chromium
* so `puppeteer.launch()` can use it instead of the bundled one.
*
* Detection order:
* 1. `chromium` on PATH
* 2. `chromium-browser` on PATH
* 3. ~/.nix-profile/bin/chromium (user profile)
* 4. /run/current-system/sw/bin/chromium (system profile)
*
* Returns `undefined` on non-NixOS systems or when no binary is found, which
* causes Puppeteer to fall back to its default resolution.
*/
let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
function resolveSystemChromium(): string | undefined {
if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
try {
if (!fs.existsSync("/etc/NIXOS")) {
_resolvedChromium = null;
return undefined;
}
} catch {
_resolvedChromium = null;
return undefined;
}
const candidates = [
Bun.which("chromium"),
Bun.which("chromium-browser"),
path.join(os.homedir(), ".nix-profile/bin/chromium"),
"/run/current-system/sw/bin/chromium",
];
for (const candidate of candidates) {
if (candidate) {
try {
if (fs.existsSync(candidate)) {
_resolvedChromium = candidate;
logger.debug("NixOS: using system Chromium", { path: candidate });
return candidate;
}
} catch {}
}
}
_resolvedChromium = null;
logger.debug("NixOS detected but no Chromium binary found; Puppeteer may fail to launch");
return undefined;
}
const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 120;
const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1.25 };
const STEALTH_IGNORE_DEFAULT_ARGS = [
"--disable-extensions",
@@ -548,6 +598,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
this.#browser = await puppeteer.launch({
headless: this.#currentHeadless,
defaultViewport: this.#currentHeadless ? initialViewport : null,
executablePath: resolveSystemChromium(),
args: launchArgs,
ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULT_ARGS],
});
@@ -1382,7 +1433,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
} else {
dest = path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`);
}
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
// Full-res buffer when saving to a user-defined location; resized (API copy) for temp-only.
const saveFullRes = !!(paramPath || screenshotDir);
const savedBuffer = saveFullRes ? buffer : resized.buffer;