diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 1a31af9dfaf..d2b0732b290 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,4 +1,4 @@ -const { chmodSync, existsSync, readdirSync } = require('node:fs') +const { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') @@ -268,6 +268,7 @@ module.exports = { } writeMacBuildCompatibility(resourcesDir, { version, commit, architecture }) } + stampPackagedCliVersion(resourcesDir, context.packager.appInfo.version) prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch) verifyPackagedMainRuntimeDeps(resourcesDir) // Why: boot the packaged daemon-entry under plain Node, but only for the @@ -561,6 +562,16 @@ module.exports = { } } +// Stamp the effective channel version where node-mode CLI code can read it. +function stampPackagedCliVersion(resourcesDir, version) { + const packageJsonPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json') + if (!existsSync(packageJsonPath)) { + throw new Error(`Missing unpacked CLI package boundary: ${packageJsonPath}`) + } + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + writeFileSync(packageJsonPath, `${JSON.stringify({ ...packageJson, version }, null, 2)}\n`) +} + function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) { if (electronPlatformName === 'win32') { return diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 347cae8fcfc..484a79f8f40 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -619,6 +619,11 @@ describe('electron-builder config', () => { 'console.error("Usage: daemon-entry "); process.exit(1)\n', 'utf8' ) + await writeFile( + join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), + `${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`, + 'utf8' + ) const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') @@ -637,10 +642,14 @@ describe('electron-builder config', () => { await electronBuilderConfig.afterPack({ appOutDir: join(root, 'linux-unpacked'), electronPlatformName: 'linux', - arch: 1 + arch: 1, + packager: { appInfo: { version: '9.9.9' } } }) expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) + await expect( + readFile(join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), 'utf8') + ).resolves.toContain('"version": "9.9.9"') } finally { await rm(root, { recursive: true, force: true }) } diff --git a/config/scripts/verify-cli-bin.mjs b/config/scripts/verify-cli-bin.mjs index a9fa71ce2e7..cdc56401262 100755 --- a/config/scripts/verify-cli-bin.mjs +++ b/config/scripts/verify-cli-bin.mjs @@ -5,15 +5,14 @@ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'nod import path from 'node:path' import { pathToFileURL } from 'node:url' -const OUT_COMMONJS_PACKAGE_JSON = `${JSON.stringify( - { - name: 'orca-compiled-output', - type: 'commonjs', - private: true - }, - null, - 2 -)}\n` +// Electron packaging restamps the channel-specific version after compilation. +function buildOutPackageJson(version) { + return `${JSON.stringify( + { name: 'orca-compiled-output', type: 'commonjs', private: true, version }, + null, + 2 + )}\n` +} /** * Verifies the published CLI entrypoint and the module-type boundary for the @@ -49,7 +48,7 @@ export function verifyPackageCliBin({ const outPackageJsonPath = path.join(projectDir, 'out', 'package.json') if (fixPackageJson) { mkdirSync(path.dirname(outPackageJsonPath), { recursive: true }) - writeFileSync(outPackageJsonPath, OUT_COMMONJS_PACKAGE_JSON, 'utf8') + writeFileSync(outPackageJsonPath, buildOutPackageJson(packageJson.version), 'utf8') } let outPackageJson try { diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 3d7db834e8e..00492968492 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -392,11 +392,13 @@ Rolling back is the case that needs care — see [Roll back](#roll-back). ### Record the version you deploy -Orca has no headless version command: there is no `--version` flag or `version` -subcommand, and `orca serve` prints only its endpoint. Choose a release tag -explicitly instead of following the `latest` URL, and record it next to the -binary so upgrades are auditable. The steps below keep that record in -`/opt/orca/VERSION`. +The bundled CLI launcher prints the Orca build with `orca-ide --version`. For an +extracted deployment, that launcher is +`squashfs-root/resources/bin/orca-ide`; deb/rpm installs and CLI registration put +it on `PATH`. Do not use `orca-linux.AppImage --version` for this audit because +Electron owns the direct binary's version flags and may report its own runtime +version. For an AppImage service, choose a release tag explicitly and record it +next to the binary. The steps below keep that record in `/opt/orca/VERSION`. ### Upgrade steps diff --git a/src/cli/cli-version.test.ts b/src/cli/cli-version.test.ts new file mode 100644 index 00000000000..6ffe60692fb --- /dev/null +++ b/src/cli/cli-version.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readOrcaCliVersion } from './cli-version' + +const temporaryDirectories: string[] = [] + +afterEach(() => + Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))) +) + +describe('CLI version', () => { + it('reads the package boundary beside the compiled CLI', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-')) + const runtimeDir = join(root, 'cli') + temporaryDirectories.push(root) + await mkdir(runtimeDir) + await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.4.178-rc.2' })) + + expect(readOrcaCliVersion(runtimeDir)).toBe('1.4.178-rc.2') + }) + + it('rejects missing, malformed, and non-string versions', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-invalid-')) + const runtimeDir = join(root, 'cli') + temporaryDirectories.push(root) + await mkdir(runtimeDir) + + expect(readOrcaCliVersion(runtimeDir)).toBeNull() + await writeFile(join(root, 'package.json'), '{') + expect(readOrcaCliVersion(runtimeDir)).toBeNull() + await writeFile(join(root, 'package.json'), JSON.stringify({ version: 178 })) + expect(readOrcaCliVersion(runtimeDir)).toBeNull() + }) +}) diff --git a/src/cli/cli-version.ts b/src/cli/cli-version.ts new file mode 100644 index 00000000000..0918ec763fd --- /dev/null +++ b/src/cli/cli-version.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +// Node-mode CLI code cannot read the package metadata inside app.asar. +export function readOrcaCliVersion(runtimeDir = __dirname): string | null { + try { + const parsed = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8')) as { + version?: unknown + } + return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null + } catch { + return null + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 2a8921a2cdb..c29bfff7060 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ import { specPaths, validateCommandAndFlags } from './args' +import { readOrcaCliVersion } from './cli-version' import { dispatch } from './dispatch' import { assertEnvironmentSelectorResolvable, @@ -59,6 +60,17 @@ export async function main( argv = process.argv.slice(2), cwd = resolveInvocationCwd() ): Promise { + // Why: version audits use the bundled launcher; Electron intercepts direct binary version flags. + if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) { + const version = readOrcaCliVersion() + if (!version) { + process.stderr.write('Could not determine the Orca version for this build.\n') + process.exitCode = 1 + return + } + process.stdout.write(`${version}\n`) + return + } if (argv[0] === 'agent-teams-tmux') { await runAgentTeamsTmuxShim(argv.slice(1)) return diff --git a/src/main/index.ts b/src/main/index.ts index 368cb7692a0..ea68f82dc9c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -169,7 +169,11 @@ import { } from './startup/main-process-error-guards' import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' -import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' +import { + ensureVirtualDisplayForHeadlessServe, + hasUsableLinuxDisplay, + MISSING_LINUX_DISPLAY_MESSAGE +} from './startup/ensure-virtual-display' import { clearGpuFallbackMarker, readActiveGpuFallbackMarker, @@ -548,6 +552,11 @@ if (argvRequestsServeMode(process.argv)) { process.argv = normalizeServeModeArgv(process.argv) } const isServeMode = process.argv.includes('--serve') +// Fail before Chromium's missing-display teardown can segfault (#13719). +if (app.isPackaged && !isServeMode && !hasUsableLinuxDisplay()) { + process.stderr.write(`${MISSING_LINUX_DISPLAY_MESSAGE}\n`) + app.exit(1) +} function updateGpuAccelerationAboutPanel(): void { app.setAboutPanelOptions( diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index 93f4bb14f77..cb181a70b5d 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -1,24 +1,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { spawnMock, spawnSyncMock, existsSyncMock, readFileSyncMock, rmSyncMock, appMock } = - vi.hoisted(() => ({ - spawnMock: vi.fn(), - spawnSyncMock: vi.fn(), - existsSyncMock: vi.fn(), - readFileSyncMock: vi.fn(), - rmSyncMock: vi.fn(), - appMock: { - disableHardwareAcceleration: vi.fn(), - commandLine: { appendSwitch: vi.fn() }, - once: vi.fn() - } - })) +const { + spawnMock, + spawnSyncMock, + existsSyncMock, + readFileSyncMock, + rmSyncMock, + statSyncMock, + appMock +} = vi.hoisted(() => ({ + spawnMock: vi.fn(), + spawnSyncMock: vi.fn(), + existsSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), + rmSyncMock: vi.fn(), + statSyncMock: vi.fn(), + appMock: { + disableHardwareAcceleration: vi.fn(), + commandLine: { appendSwitch: vi.fn(), getSwitchValue: vi.fn() }, + once: vi.fn() + } +})) vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: spawnSyncMock })) vi.mock('fs', () => ({ existsSync: existsSyncMock, readFileSync: readFileSyncMock, - rmSync: rmSyncMock + rmSync: rmSyncMock, + statSync: statSyncMock })) vi.mock('electron', () => ({ app: appMock })) @@ -29,6 +38,13 @@ function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform, configurable: true }) } +function mockLiveXDisplay(pid = 4321): void { + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(`${pid}\n`) + vi.spyOn(process, 'kill').mockImplementation(() => true) +} + describe('ensureVirtualDisplayForHeadlessServe', () => { beforeEach(() => { spawnMock.mockReset() @@ -36,8 +52,10 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { existsSyncMock.mockReset() readFileSyncMock.mockReset() rmSyncMock.mockReset() + statSyncMock.mockReset() appMock.disableHardwareAcceleration.mockReset() appMock.commandLine.appendSwitch.mockReset() + appMock.commandLine.getSwitchValue.mockReset().mockReturnValue('') appMock.once.mockReset() delete process.env.DISPLAY }) @@ -76,6 +94,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { it('reuses an externally provided DISPLAY without starting Xvfb', async () => { setPlatform('linux') process.env.DISPLAY = ':0' + mockLiveXDisplay() const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) @@ -89,14 +108,20 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { it('reports unsupported (no spawn) when Xvfb is not installed', async () => { setPlatform('linux') spawnSyncMock.mockReturnValue({ status: 1 }) // `which Xvfb` fails - const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + const { ensureVirtualDisplayForHeadlessServe, MISSING_LINUX_DISPLAY_MESSAGE } = + await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) expect(spawnMock).not.toHaveBeenCalled() + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('endpoint is unavailable') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('XDG_RUNTIME_DIR') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('`xvfb` on Debian/Ubuntu') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('`xorg-x11-server-Xvfb`') }) - it('starts Xvfb and switches to software rendering when none exists', async () => { + it('starts Xvfb when the configured local display is stale', async () => { setPlatform('linux') + process.env.DISPLAY = ':77' spawnSyncMock.mockReturnValue({ status: 0 }) // `which Xvfb` succeeds // First existsSync (stale-socket check) false; later (socket-ready poll) true. existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true) @@ -163,4 +188,112 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { expect(process.env.DISPLAY).toBe(':99') killSpy.mockRestore() }) + + describe('hasUsableLinuxDisplay', () => { + it('accepts live local X11 and Wayland sockets', async () => { + setPlatform('linux') + mockLiveXDisplay() + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + expect( + hasUsableLinuxDisplay({ + WAYLAND_DISPLAY: 'wayland-0', + XDG_RUNTIME_DIR: '/run/user/1000' + }) + ).toBe(true) + expect(statSyncMock).toHaveBeenCalledWith('/tmp/.X11-unix/X0') + expect(statSyncMock).toHaveBeenCalledWith('/run/user/1000/wayland-0') + }) + + it('rejects an orphaned local X11 socket without a live server lock', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(false) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':77' })).toBe(false) + expect(existsSyncMock).toHaveBeenCalledWith('/tmp/.X77-lock') + }) + + it('accepts a live local X11 server owned by another user', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue('4321\n') + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('not permitted'), { code: 'EPERM' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + }) + + it('rejects absent, blank, and stale local displays', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw new Error('ENOENT') + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({})).toBe(false) + expect(hasUsableLinuxDisplay({ DISPLAY: ' ', WAYLAND_DISPLAY: '' })).toBe(false) + expect(hasUsableLinuxDisplay({ DISPLAY: ':77' })).toBe(false) + expect( + hasUsableLinuxDisplay({ WAYLAND_DISPLAY: 'wayland-0', XDG_RUNTIME_DIR: '/run/user/1000' }) + ).toBe(false) + expect(hasUsableLinuxDisplay({ WAYLAND_DISPLAY: 'wayland-0' })).toBe(false) + }) + + it.each([ + ['localhost:10.0', true], + ['build-host.example:1', true], + ['[2001:db8::1]:2.0', true], + ['tcp/build-host.example:3', true], + ['garbage', false], + ['build host:1', false], + ['build-host.example:', false], + ['build-host.example:abc', false] + ])('validates remote X display syntax for %s', async (display, expected) => { + setPlatform('linux') + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: display })).toBe(expected) + expect(statSyncMock).not.toHaveBeenCalled() + }) + + it('honors forced X11 and Wayland platform selection', async () => { + setPlatform('linux') + statSyncMock.mockImplementation((path: string) => ({ + isSocket: () => path === '/run/user/1000/wayland-0' + })) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + const env = { + DISPLAY: ':77', + WAYLAND_DISPLAY: 'wayland-0', + XDG_RUNTIME_DIR: '/run/user/1000' + } + + appMock.commandLine.getSwitchValue.mockReturnValue('x11') + expect(hasUsableLinuxDisplay(env)).toBe(false) + appMock.commandLine.getSwitchValue.mockReturnValue('wayland') + expect(hasUsableLinuxDisplay(env)).toBe(true) + + statSyncMock.mockImplementation((path: string) => ({ + isSocket: () => path === '/tmp/.X11-unix/X0' + })) + expect(hasUsableLinuxDisplay({ ...env, DISPLAY: ':0' })).toBe(false) + + appMock.commandLine.getSwitchValue.mockReturnValue('') + expect(hasUsableLinuxDisplay({ ...env, ELECTRON_OZONE_PLATFORM_HINT: 'x11' })).toBe(false) + }) + + it('never gates a non-Linux platform', async () => { + setPlatform('darwin') + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({})).toBe(true) + expect(statSyncMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/startup/ensure-virtual-display.ts b/src/main/startup/ensure-virtual-display.ts index a9d517f9f99..764b7da0982 100644 --- a/src/main/startup/ensure-virtual-display.ts +++ b/src/main/startup/ensure-virtual-display.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process' -import { existsSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, rmSync, statSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' import { app } from 'electron' // Why: headless `orca serve` backs browser panes with offscreen BrowserWindows. @@ -12,6 +13,8 @@ const XVFB_STARTUP_TIMEOUT_MS = 5_000 const XVFB_POLL_INTERVAL_MS = 50 const VIRTUAL_DISPLAY_NUMBER = 99 const VIRTUAL_DISPLAY = `:${VIRTUAL_DISPLAY_NUMBER}` +const XVFB_INSTALL_GUIDANCE = + 'Install `xvfb` on Debian/Ubuntu or `xorg-x11-server-Xvfb` on RPM-based systems.' let xvfbProcess: ChildProcess | null = null @@ -54,8 +57,8 @@ function isDisplayServerAlive(displayNumber: number): boolean { // signal 0 probes existence without affecting the process. process.kill(pid, 0) return true - } catch { - return false + } catch (error) { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPERM' } } @@ -95,6 +98,72 @@ function waitForDisplaySocket(displayNumber: number, deadline: number): boolean return existsSync(socket) } +// Validate display syntax and local sockets before Chromium reaches Ozone initialization. +export function hasUsableLinuxDisplay(env: NodeJS.ProcessEnv = process.env): boolean { + if (process.platform !== 'linux') { + return true + } + + const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform').trim().toLowerCase() + const ozonePlatformHint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase() + const selectedPlatform = + ozonePlatform === 'x11' || ozonePlatform === 'wayland' + ? ozonePlatform + : ozonePlatformHint === 'x11' || ozonePlatformHint === 'wayland' + ? ozonePlatformHint + : null + + if (selectedPlatform === 'x11') { + return hasUsableXDisplay(env.DISPLAY) + } + if (selectedPlatform === 'wayland') { + return hasUsableWaylandDisplay(env) + } + return hasUsableXDisplay(env.DISPLAY) || hasUsableWaylandDisplay(env) +} + +export const MISSING_LINUX_DISPLAY_MESSAGE = [ + 'Orca needs a usable display server, but the selected X11 or Wayland endpoint is unavailable.', + 'Check DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR, and any --ozone-platform override.', + `Use \`orca-ide serve\` to run headless. On a bare server, ${XVFB_INSTALL_GUIDANCE}` +].join('\n') + +function isUnixSocket(path: string): boolean { + try { + return statSync(path).isSocket() + } catch { + return false + } +} + +function hasUsableXDisplay(value: string | undefined): boolean { + const display = value?.trim() + if (!display) { + return false + } + + const localDisplay = /^(?:unix\/?)?:(\d+)(?:\.\d+)?$/i.exec(display) + // Remote endpoints cannot be proven with local socket checks. + if (!localDisplay) { + return /^\S+:\d+(?:\.\d+)?$/.test(display) + } + const displayNumber = Number(localDisplay[1]) + return isUnixSocket(xvfbSocketPath(displayNumber)) && isDisplayServerAlive(displayNumber) +} + +function hasUsableWaylandDisplay(env: NodeJS.ProcessEnv): boolean { + const display = env.WAYLAND_DISPLAY?.trim() + if (!display) { + return false + } + if (isAbsolute(display)) { + return isUnixSocket(display) + } + + const runtimeDir = env.XDG_RUNTIME_DIR?.trim() + return Boolean(runtimeDir && isAbsolute(runtimeDir) && isUnixSocket(join(runtimeDir, display))) +} + /** * Ensure a usable X display for headless Linux serve. Returns true when a * display is available (pre-existing or freshly started), false when browser @@ -107,16 +176,15 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo configureHeadlessServeChromiumFlags() - // Why: respect an externally provided display (a real X server, or the image - // already running its own Xvfb). Don't start a competing one. - if (process.env.DISPLAY && process.env.DISPLAY.trim().length > 0) { + // Offscreen serve windows require X11; Wayland alone still needs Xvfb. + if (hasUsableXDisplay(process.env.DISPLAY)) { return true } if (!hasXvfbBinary()) { console.warn( '[serve] Xvfb not found; browser panes are unavailable on this headless Linux host. ' + - 'Install Xvfb (e.g. `apt-get install xvfb`) or set DISPLAY to enable them.' + `${XVFB_INSTALL_GUIDANCE} Set DISPLAY to enable them with an existing X server.` ) return false }