From 7e8e9e4dc144a968c8ee33ff729f1292011c5ed6 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 24 Jun 2026 16:38:00 +0900 Subject: [PATCH 1/3] fix(docker): Give the browser a writable HOME for UIDs missing from /etc/passwd --- .changeset/browser-writable-home.md | 5 +++++ src/browser.ts | 23 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .changeset/browser-writable-home.md diff --git a/.changeset/browser-writable-home.md b/.changeset/browser-writable-home.md new file mode 100644 index 00000000..2d5f933d --- /dev/null +++ b/.changeset/browser-writable-home.md @@ -0,0 +1,5 @@ +--- +'@vivliostyle/cli': patch +--- + +Give the browser a writable `HOME` for UIDs missing from `/etc/passwd`. diff --git a/src/browser.ts b/src/browser.ts index f28940d0..941c0bdb 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import type { BrowserPlatform, @@ -216,9 +217,17 @@ async function launchBrowser({ protocolTimeout, } satisfies LaunchOptions; Logger.debug('launchOptions %O', launchOptions); + // Browsers store transient data that never affects rendering (crash dumps, + // profiles.ini) under HOME (chrome_paths_linux.cc, nsXREDirProvider.cpp). A + // container UID with no /etc/passwd entry (e.g. `docker run --user`) + // gets the unwritable HOME `/`, so the browser cannot launch (see #835). + const env: NodeJS.ProcessEnv = { ...process.env, LANG: 'en.UTF-8' }; + if (process.platform === 'linux' && !isWritableDir(env.HOME)) { + env.HOME = os.tmpdir(); + } const browserLaunch = puppeteer.launch({ ...launchOptions, - env: { ...process.env, LANG: 'en.UTF-8' }, + env, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, @@ -232,6 +241,18 @@ async function launchBrowser({ return { browser, browserContext, closeBrowser }; } +function isWritableDir(dir: string | undefined): boolean { + if (!dir) { + return false; + } + try { + fs.accessSync(dir, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + function getPuppeteerCacheDir() { if (isInContainer()) { return '/opt/puppeteer'; From 29c51403c0afb8465c2b033114ae798fd0acdc63 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 24 Jun 2026 23:43:44 +0900 Subject: [PATCH 2/3] refactor: Use a private temp dir for the browser's fallback HOME --- src/browser.ts | 9 +++-- tests/browser.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/browser.ts b/src/browser.ts index 941c0bdb..f2e8a0db 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,5 +1,4 @@ import fs from 'node:fs'; -import os from 'node:os'; import type { BrowserPlatform, @@ -26,6 +25,7 @@ import { isRunningOnWSL, registerCleanupHandler, toError, + useTmpDirectory, } from './util.js'; /* oxlint-disable typescript/no-unsafe-type-assertion */ @@ -223,7 +223,7 @@ async function launchBrowser({ // gets the unwritable HOME `/`, so the browser cannot launch (see #835). const env: NodeJS.ProcessEnv = { ...process.env, LANG: 'en.UTF-8' }; if (process.platform === 'linux' && !isWritableDir(env.HOME)) { - env.HOME = os.tmpdir(); + [env.HOME] = await useTmpDirectory(); } const browserLaunch = puppeteer.launch({ ...launchOptions, @@ -246,7 +246,10 @@ function isWritableDir(dir: string | undefined): boolean { return false; } try { - fs.accessSync(dir, fs.constants.W_OK); + if (!fs.statSync(dir).isDirectory()) { + return false; + } + fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK); return true; } catch { return false; diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 2e7b8672..d1eacca7 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -1,4 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'node:os'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockedLaunch = vi.hoisted(() => vi.fn<(options?: unknown) => Promise>(), @@ -6,6 +8,11 @@ const mockedLaunch = vi.hoisted(() => const mockedRegisterCleanupHandler = vi.hoisted(() => vi.fn<(message: string, handler: () => void | Promise) => void>(), ); +const mockedUseTmpDirectory = vi.hoisted(() => + vi.fn<() => Promise<[string, () => void]>>(() => + Promise.resolve(['/tmp/vivliostyle-home-test', () => {}]), + ), +); vi.mock('../src/node-modules.js', () => ({ importNodeModule: vi.fn<() => Promise>(() => @@ -20,6 +27,7 @@ vi.mock('../src/util.js', async (importOriginal) => { return { ...actual, registerCleanupHandler: mockedRegisterCleanupHandler, + useTmpDirectory: mockedUseTmpDirectory, }; }); @@ -175,3 +183,86 @@ describe('launchPreview', () => { await expect(launching).rejects.toThrow(Error); }); }); + +describe('writable HOME fallback', () => { + const originalPlatform = process.platform; + const setPlatform = (value: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockedLaunch.mockResolvedValue({ + browserContexts: () => [ + { + pages: () => [], + newPage: () => ({ + setViewport: vi.fn<() => void>(), + on: vi.fn<() => void>(), + authenticate: vi.fn<() => void>(), + goto: vi.fn<() => void>(), + }), + }, + ], + close: vi.fn<() => void>(), + }); + }); + + afterEach(() => { + setPlatform(originalPlatform); + vi.unstubAllEnvs(); + }); + + const launch = () => + launchPreview({ + mode: 'build', + url: 'https://example.com', + config: { + browser: { + type: 'chrome', + tag: 'stable', + executablePath: process.execPath, + }, + proxy: undefined, + sandbox: false, + ignoreHttpsErrors: false, + timeout: 1000, + }, + }); + + const launchedEnv = () => + (mockedLaunch.mock.calls[0][0] as { env: NodeJS.ProcessEnv }).env; + + it('replaces an unwritable HOME with a temp directory on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('HOME', '/__vivliostyle_nonexistent_home__'); + + await launch(); + + expect(mockedUseTmpDirectory).toHaveBeenCalledOnce(); + expect(launchedEnv().HOME).toBe('/tmp/vivliostyle-home-test'); + }); + + it('keeps a writable HOME untouched on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('HOME', os.tmpdir()); + + await launch(); + + expect(mockedUseTmpDirectory).not.toHaveBeenCalled(); + expect(launchedEnv().HOME).toBe(os.tmpdir()); + }); + + it('does not touch HOME on non-Linux platforms', async () => { + setPlatform('darwin'); + vi.stubEnv('HOME', '/__vivliostyle_nonexistent_home__'); + + await launch(); + + expect(mockedUseTmpDirectory).not.toHaveBeenCalled(); + expect(launchedEnv().HOME).toBe('/__vivliostyle_nonexistent_home__'); + }); +}); From 4f5ab6d42fbe1ae0ccd2ed9b83700f555949f2d9 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Sun, 5 Jul 2026 18:45:38 +0900 Subject: [PATCH 3/3] Update src/browser.ts Co-authored-by: Akihiro Tamada --- src/browser.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser.ts b/src/browser.ts index f2e8a0db..8e653e24 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -217,11 +217,12 @@ async function launchBrowser({ protocolTimeout, } satisfies LaunchOptions; Logger.debug('launchOptions %O', launchOptions); + // #416: set Chromium language to English to avoid locale-dependent issues + const env: NodeJS.ProcessEnv = { ...process.env, LANG: 'en.UTF-8' }; // Browsers store transient data that never affects rendering (crash dumps, // profiles.ini) under HOME (chrome_paths_linux.cc, nsXREDirProvider.cpp). A // container UID with no /etc/passwd entry (e.g. `docker run --user`) // gets the unwritable HOME `/`, so the browser cannot launch (see #835). - const env: NodeJS.ProcessEnv = { ...process.env, LANG: 'en.UTF-8' }; if (process.platform === 'linux' && !isWritableDir(env.HOME)) { [env.HOME] = await useTmpDirectory(); }