diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4729a5061e..e7ecafc0227 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ A defence-in-depth pass across the API, token, export, and deployment surfaces:
- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F` → `%252F`). The admin Open `window.open` gains `noopener,noreferrer`.
- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests.
- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`.
+- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists.
- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `
` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views.
### Internal / contributor-facing
diff --git a/Dockerfile b/Dockerfile
index 56d65a76ff2..be7a201ca77 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,6 +7,11 @@
# docker build --build-arg BUILD_ENV=copy .
ARG BUILD_ENV=git
+# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm
+# 11.1.x enforces the minimum-release-age supply-chain policy during install,
+# which the frozen-lockfile Docker build can't satisfy, so the image stays on
+# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in
+# the build stage below — see ether/etherpad#7911.
ARG PnpmVersion=11.0.6
FROM node:24-alpine AS adminbuild
@@ -28,6 +33,17 @@ RUN pnpm run build:ui
FROM node:24-alpine AS build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
+# The image's pnpm intentionally lags the "packageManager" pin (see the ARG
+# note above). pnpm would otherwise try to self-provision the pinned version on
+# invocation — including the informational `pnpm --version` probe Etherpad runs
+# at startup — which fails closed with no network and breaks air-gapped boots
+# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version
+# instead. Inherited by the development and production runtime stages, so it
+# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec
+# shell. It does not change which pnpm runs the build-time install (still the
+# installed 11.0.x), so the frozen-lockfile build is unaffected.
+ENV pnpm_config_pm_on_fail=ignore
+
# Set these arguments when building the image from behind a proxy
ARG http_proxy=
ARG https_proxy=
diff --git a/src/node/hooks/express/updateActions.ts b/src/node/hooks/express/updateActions.ts
index 0b4f1a2aea1..4f90f009880 100644
--- a/src/node/hooks/express/updateActions.ts
+++ b/src/node/hooks/express/updateActions.ts
@@ -84,7 +84,11 @@ const buildPreflightDeps = (installMethod: ReturnType new Promise((resolve) => {
- const c = spawn('pnpm', ['--version'], {stdio: 'ignore'});
+ // pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm
+ // exit non-zero (it would otherwise try to fetch the pinned build, which
+ // fails offline) and falsely report pnpm as absent. See #7911.
+ const c = spawn('pnpm', ['--version'],
+ {stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
c.on('close', (code) => resolve(code === 0));
c.on('error', () => resolve(false));
}),
diff --git a/src/node/updater/index.ts b/src/node/updater/index.ts
index 9a8a332e964..aa15dfbf9f8 100644
--- a/src/node/updater/index.ts
+++ b/src/node/updater/index.ts
@@ -297,7 +297,11 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({
}
},
pnpmOnPath: () => new Promise((resolve) => {
- const c = spawn('pnpm', ['--version'], {stdio: 'ignore'});
+ // pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm
+ // exit non-zero (it would otherwise try to fetch the pinned build, which
+ // fails offline) and falsely report pnpm as absent. See #7911.
+ const c = spawn('pnpm', ['--version'],
+ {stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
c.on('close', (code) => resolve(code === 0));
c.on('error', () => resolve(false));
}),
diff --git a/src/static/js/pluginfw/plugins.ts b/src/static/js/pluginfw/plugins.ts
index f2960138ac8..ab5d2889663 100644
--- a/src/static/js/pluginfw/plugins.ts
+++ b/src/static/js/pluginfw/plugins.ts
@@ -20,9 +20,16 @@ const logger = log4js.getLogger('plugins');
// installs go through live-plugin-manager directly), so a missing pnpm
// is expected on hardened/packaged installs and shouldn't produce an
// ERROR in the logs.
+//
+// pnpm_config_pm_on_fail=ignore keeps this informational probe from failing
+// when the installed pnpm differs from package.json's "packageManager" pin:
+// by default pnpm would try to download the pinned version, which throws in
+// air-gapped/firewalled installs (ether/etherpad#7911). We only want to read
+// the local version here, never to self-provision a different one.
(async () => {
try {
- const version = await runCmd(['pnpm', '--version'], {stdio: [null, 'string']});
+ const version = await runCmd(['pnpm', '--version'],
+ {stdio: [null, 'string'], env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
logger.info(`pnpm --version: ${version}`);
} catch (err) {
if ((err as any).code === 'ENOENT') {
diff --git a/src/tests/backend/specs/dockerfilePnpmPin.ts b/src/tests/backend/specs/dockerfilePnpmPin.ts
new file mode 100644
index 00000000000..94a15a8c0dc
--- /dev/null
+++ b/src/tests/backend/specs/dockerfilePnpmPin.ts
@@ -0,0 +1,80 @@
+'use strict';
+
+// Regression tests for ether/etherpad#7911.
+//
+// The official Docker image installs pnpm directly (corepack was dropped for
+// Node 25+). Standalone pnpm still honours the "packageManager" pin in
+// package.json: when the pnpm baked into the image differs from that pin, pnpm
+// treats every invocation — including the informational `pnpm --version` probe
+// Etherpad runs at startup — as a request to self-provision the pinned build.
+// With no outbound network (air-gapped / behind a corporate firewall) that
+// download fails and pnpm exits non-zero, surfacing as `Failed to get pnpm
+// version` and breaking offline boots.
+//
+// The image deliberately lags the pin (pnpm 11.1.x enforces a minimum-release-
+// age policy the frozen-lockfile build can't satisfy), so the guard is not to
+// force the versions equal but to neutralise the gap: the Dockerfile must set
+// pnpm_config_pm_on_fail=ignore so pnpm uses the installed version instead of
+// reaching for the network. This test fails if that guard is dropped while a
+// version gap exists.
+
+const assert = require('assert').strict;
+import fs from 'fs';
+import path from 'path';
+
+const repoRoot = path.join(__dirname, '../../../../');
+const readRepoFile = (rel: string) => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
+
+describe(__filename, function () {
+ describe('Docker pnpm offline guard (issue #7911)', function () {
+ let dockerfile: string;
+ let imagePnpm: string;
+ let pinPnpm: string;
+ let guardPresent: boolean;
+
+ before(function () {
+ dockerfile = readRepoFile('Dockerfile');
+
+ const argMatch = dockerfile.match(/^ARG PnpmVersion=(\S+)/m);
+ assert.ok(argMatch, 'Dockerfile must declare `ARG PnpmVersion=`');
+ imagePnpm = argMatch![1];
+
+ const pkg = JSON.parse(readRepoFile('package.json'));
+ const pinMatch = String(pkg.packageManager || '').match(/^pnpm@(.+)$/);
+ assert.ok(pinMatch, `expected packageManager "pnpm@", got "${pkg.packageManager}"`);
+ pinPnpm = pinMatch![1];
+
+ // The guard only protects runtime if it lives in the `build` stage, which
+ // the development/production runtime stages inherit from. The same ENV in
+ // the throwaway `adminbuild` stage would NOT reach runtime, so scope the
+ // check to the build stage block (from `FROM ... AS build` to the next
+ // `FROM`) rather than matching anywhere in the file.
+ const buildStage = dockerfile.match(
+ /^FROM\s+\S+\s+AS\s+build\b[\s\S]*?(?=^FROM\s)/m);
+ assert.ok(buildStage, 'Dockerfile must define a `FROM ... AS build` stage');
+ guardPresent = /ENV\s+pnpm_config_pm_on_fail=ignore/.test(buildStage![0]);
+ });
+
+ it('neutralises any pnpm version gap so offline boots do not self-provision', function () {
+ // The actual safety property: a mismatch between the image pnpm and the
+ // package.json pin is only safe when pm_on_fail=ignore is set. (If they
+ // are ever realigned, the guard becomes belt-and-suspenders, not required.)
+ if (imagePnpm !== pinPnpm) {
+ assert.ok(guardPresent,
+ `Dockerfile pnpm ${imagePnpm} differs from package.json pnpm@${pinPnpm}, ` +
+ 'but pnpm_config_pm_on_fail=ignore is not set — pnpm will try to ' +
+ 'self-provision the pinned version and break offline/air-gapped ' +
+ 'startup (issue #7911).');
+ }
+ });
+
+ it('sets pnpm_config_pm_on_fail=ignore in the runtime-inherited build stage', function () {
+ assert.ok(guardPresent,
+ 'The `build` stage must set pnpm_config_pm_on_fail=ignore (it is ' +
+ 'inherited by the development/production runtime stages) so runtime ' +
+ 'pnpm calls — the startup probe, the updater pnpm check — do not fail ' +
+ 'closed when offline (issue #7911). Setting it only in `adminbuild` ' +
+ 'would not reach runtime.');
+ });
+ });
+});