fix(plugins): scope virtual display providers

Thanks to @shauneccles for reporting #9527 and identifying the filesystem-order collision.
This commit is contained in:
Pradeep Elankumaran
2026-09-09 09:48:55 -07:00
parent 396c9685ee
commit 771b610a7b
9 changed files with 79 additions and 14 deletions
+27
View File
@@ -0,0 +1,27 @@
export function createVirtualDisplayRegistry(defaultFactory) {
let provider = { name: 'core', factory: defaultFactory };
return {
register(pluginName, factory) {
if (typeof factory !== 'function') {
throw new TypeError(`virtual display provider for plugin "${pluginName}" must be a function`);
}
if (provider.name !== 'core') {
const error = new Error(
`virtual display provider conflict: "${provider.name}" already owns the capability; "${pluginName}" cannot also provide it`
);
error.code = 'plugin_capability_conflict';
throw error;
}
provider = { name: pluginName, factory };
},
create() {
return provider.factory();
},
get owner() {
return provider.name;
},
};
}
+12 -3
View File
@@ -140,8 +140,8 @@ export function createPluginEvents() {
* Load and register all plugins from plugins/<name>/index.js.
*
* @param {object} app - Express app
* @param {object} ctx - Plugin context: { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession }
* Mutable -- plugins can replace ctx.createVirtualDisplay etc.
* @param {object} ctx - Shared plugin context. Each registration receives `ctx.plugin`,
* containing that plugin's name, settings, and scoped capabilities.
* @returns {string[]} - Names of loaded plugins
*/
export async function loadPlugins(app, ctx, options = {}) {
@@ -191,10 +191,19 @@ export async function loadPlugins(app, ctx, options = {}) {
}
const pluginConfig = pluginConfigs.get(name) || {};
await register(app, ctx, pluginConfig);
const pluginCtx = Object.create(ctx);
pluginCtx.plugin = {
name,
settings: pluginConfig,
registerVirtualDisplayProvider(factory) {
return ctx.registerVirtualDisplayProvider(name, factory);
},
};
await register(app, pluginCtx, pluginConfig);
loaded.push(name);
ctx.log('info', 'plugin loaded', { plugin: name });
} catch (err) {
if (err?.code === 'plugin_capability_conflict') throw err;
ctx.log('error', 'plugin load failed', { plugin: name, error: err.message, stack: err.stack });
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ Disabled by default. Enable with `ENABLE_VNC=1` env var or `"vnc": { "enabled":
## Architecture
The plugin overrides `ctx.createVirtualDisplay` to use a higher-resolution display (default 1920x1080 instead of 1x1). `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top.
The plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a higher-resolution display (default 1920x1080 instead of 1x1). A second plugin cannot silently replace that provider. `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top.
## Original Contributors
+1 -1
View File
@@ -16,7 +16,7 @@ noVNC / websockify (port 6080)
Your browser → http://localhost:6080/vnc.html
```
The plugin overrides Camoufox's default 1x1 virtual display with a human-usable resolution, then runs a watcher process that detects the Xvfb display and attaches x11vnc + noVNC. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches.
The plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a human-usable resolution instead of the default 1x1 display. A second enabled display plugin cannot silently replace that provider. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches.
## Quick start
+5 -4
View File
@@ -50,9 +50,10 @@ import { removeXvfbDisplayFiles } from '../../lib/tmp-cleanup.js';
export async function register(app, ctx, pluginConfig = {}) {
const { events, config, log, sessions, VirtualDisplay, safeError } = ctx;
const settings = ctx.plugin?.settings || pluginConfig;
// Resolve all config (env vars + pluginConfig) via the launcher module
const vncConfig = resolveVncConfig(pluginConfig);
// Resolve all config (env vars + plugin settings) via the launcher module
const vncConfig = resolveVncConfig(settings);
if (!vncConfig.enabled) {
log('info', 'vnc plugin: disabled (set ENABLE_VNC=1 or plugins.vnc.enabled=true)');
@@ -86,8 +87,8 @@ export async function register(app, ctx, pluginConfig = {}) {
}
}
ctx.createVirtualDisplay = () => new VncVirtualDisplay();
log('info', 'vnc plugin: overriding Xvfb resolution', { resolution });
ctx.plugin.registerVirtualDisplayProvider(() => new VncVirtualDisplay());
log('info', 'vnc plugin: registered Xvfb display provider', { resolution });
// --- VNC watcher process ---
log('info', 'vnc plugin enabled', {
+5 -1
View File
@@ -70,6 +70,7 @@ describe('vnc plugin', () => {
mockApp = {
get: jest.fn((path, ...handlers) => { routes[`GET ${path}`] = handlers; }),
};
const displayProvider = { create: () => new MockVirtualDisplay() };
ctx = {
events,
config: {},
@@ -77,8 +78,11 @@ describe('vnc plugin', () => {
sessions: new Map(),
safeError: (err) => typeof err === 'string' ? err : (err?.message || 'Internal error'),
VirtualDisplay: MockVirtualDisplay,
createVirtualDisplay: () => new MockVirtualDisplay(),
plugin: {
registerVirtualDisplayProvider: (factory) => { displayProvider.create = factory; },
},
};
ctx.createVirtualDisplay = () => displayProvider.create();
mockStartWatcher.mockClear();
mockStartWatcher.mockImplementation(mockWatcher);
mockResolveVncConfig.mockClear();
+5 -3
View File
@@ -48,6 +48,7 @@ import { createReporter, createTabHealthTracker, collectResourceSnapshot, classi
import { mountDocs } from './lib/openapi.js';
import { initSentry, captureException as sentryCaptureException, setupExpressErrorHandler as setupSentryErrorHandler, flush as sentryFlush } from './lib/sentry.js';
import { prepareExternalCamoufoxExecutable } from './lib/camoufox-executable.js';
import { createVirtualDisplayRegistry } from './lib/plugin-capabilities.js';
import { killProcessIds } from './lib/browser-processes.js';
import { snapshotOwnedBrowserProcesses, survivingOwnedBrowserProcesses, profilePathsFromProcessSnapshot } from './lib/process-ownership.js';
import {
@@ -1082,6 +1083,8 @@ function isCamoufoxGeoipError(err) {
return /Invalid locale:|GeoLite|MaxMind|geolocation|public proxy IP address|GeoIP setup timed out/i.test(err?.message || String(err || ''));
}
const virtualDisplayRegistry = createVirtualDisplayRegistry(() => new DefaultVirtualDisplay());
async function buildLaunchOptionsWithGeoipFallback(baseOptions, attemptMeta) {
try {
return await withTimeout(launchOptions(baseOptions), GEOIP_SETUP_TIMEOUT_MS, 'GeoIP setup');
@@ -1114,7 +1117,7 @@ async function launchBrowserInstance() {
let candidateBrowser = null;
try {
if (os.platform() === 'linux' && !useDesktopWindow) {
localVirtualDisplay = pluginCtx.createVirtualDisplay();
localVirtualDisplay = virtualDisplayRegistry.create();
vdDisplay = await localVirtualDisplay.get();
log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
}
@@ -7012,8 +7015,7 @@ const pluginCtx = {
failuresTotal,
metricsRegistry: getRegister,
createMetric,
/** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */
createVirtualDisplay: () => new DefaultVirtualDisplay(),
registerVirtualDisplayProvider: (pluginName, factory) => virtualDisplayRegistry.register(pluginName, factory),
/** The upstream VirtualDisplay class -- plugins can subclass it. */
VirtualDisplay,
};
+1 -1
View File
@@ -31,7 +31,7 @@ describe('launch compatibility source contract', () => {
expect(defaultVirtualDisplay).toContain("DEFAULT_VIRTUAL_DISPLAY_RESOLUTION = '1280x720x24'");
expect(defaultVirtualDisplay).toContain('class DefaultVirtualDisplay extends VirtualDisplay');
expect(defaultVirtualDisplay).toContain('patched[idx + 1] = DEFAULT_VIRTUAL_DISPLAY_RESOLUTION');
expect(pluginContext).toContain('createVirtualDisplay: () => new DefaultVirtualDisplay()');
expect(pluginContext).toContain('registerVirtualDisplayProvider: (pluginName, factory) => virtualDisplayRegistry.register(pluginName, factory)');
});
test('does not configure a fixed default browser context viewport', () => {
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from '@jest/globals';
import { createVirtualDisplayRegistry } from '../../lib/plugin-capabilities.js';
describe('virtual display plugin capability', () => {
test('uses the registered plugin provider instead of the default provider', () => {
const registry = createVirtualDisplayRegistry(() => ({ owner: 'core' }));
registry.register('vnc', () => ({ owner: 'vnc' }));
expect(registry.owner).toBe('vnc');
expect(registry.create()).toEqual({ owner: 'vnc' });
});
test('rejects a second plugin provider instead of relying on load order', () => {
const registry = createVirtualDisplayRegistry(() => ({ owner: 'core' }));
registry.register('vnc', () => ({ owner: 'vnc' }));
expect(() => registry.register('other-display', () => ({ owner: 'other' }))).toThrow(
'"vnc" already owns the capability; "other-display" cannot also provide it'
);
});
});