From 6f46101b9eff73dd02dbc858900e655a2263de19 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Sat, 1 Aug 2026 16:50:54 +0700 Subject: [PATCH] fix(config): clear every blank compose forward so data/.env.generated is not shadowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose.yml forwards a variable as `- KEY=${KEY:-}` so a real host value reaches the container. With nothing set, that renders an empty value, which still occupies process.env and blocks the project .env and data/.env.generated layers — both loaded with dotenv override:false, which will not replace a key that is already present, blank or not. clearBlankEnv deletes those blanks before the file layers load, but its key list had drifted behind the compose file. Eight forwarded variables had no entry, so a value set for any of them in data/.env.generated was discarded with no error: AUTO_START_SESSIONS, BODY_SIZE_LIMIT, API_MASTER_KEY, TRUSTED_PROXIES, CSP_UPGRADE_INSECURE_REQUESTS, WWEBJS_WEB_VERSION, WWEBJS_WEB_VERSION_REMOTE_PATH and WWEBJS_AUTH_TIMEOUT_MS. AUTO_START_SESSIONS is the one operators noticed. In 0.11.1 and earlier it had no route into the container at all; 0.12.0 added the forward without the clear entry, which relocated the failure rather than ending it. The flag still resolved to off, SessionService's bootstrap hook returned before querying a session, and authenticated sessions stayed at disconnected with no engine and a null lastError. Every read site already treats a blank value as unset, so clearing the blank changes nothing for a deployment that sets nothing — it only stops the blank from blocking the file layers. The clear list is now covered by a test that derives the expected set from docker-compose.yml, so a forward added without its clear entry fails in CI instead of shipping inert. Closes #981 --- CHANGELOG.md | 22 +++++++++++++++ src/config/env-precedence.spec.ts | 47 +++++++++++++++++++++++++++++++ src/config/env-precedence.ts | 30 ++++++++++++++++---- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e7dfde6..2baaf7ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with no registry entry at all. Nothing is skipped in the process: there is no runtime in this state, so no lifecycle hook goes unrun and no worker is left behind. +- **Eight settings written to `data/.env.generated` were silently ignored under the bundled Docker + Compose stack.** `docker-compose.yml` forwards a variable as `- KEY=${KEY:-}` so a real host value + reaches the container; with nothing set, that line renders an *empty* value. An empty value still + occupies `process.env`, and both lower-priority layers — the project `.env` and + `data/.env.generated` — are loaded with dotenv `override: false`, which will not replace a key that + is already present, blank or not. `clearBlankEnv` exists to delete exactly those blanks before the + files load, but its key list had drifted behind the compose file: `AUTO_START_SESSIONS`, + `BODY_SIZE_LIMIT`, `API_MASTER_KEY`, `TRUSTED_PROXIES`, `CSP_UPGRADE_INSECURE_REQUESTS`, + `WWEBJS_WEB_VERSION`, `WWEBJS_WEB_VERSION_REMOTE_PATH` and `WWEBJS_AUTH_TIMEOUT_MS` were forwarded + but never cleared. Setting any of them in `data/.env.generated` — a file the first-run header + invites operators to edit directly — did nothing, with no error and no warning. + + `AUTO_START_SESSIONS` is the one that got noticed. It gained its compose forward in 0.12.0; in + 0.11.1 and earlier the variable had no route into the container at all, which is why the flag + appeared inert across the 0.7–0.11 range. Adding the forward without the matching clear entry + relocated the failure instead of ending it: the flag still resolved to off, `SessionService`'s + bootstrap hook returned before it looked at a single session, and previously authenticated sessions + stayed at `disconnected` with no engine ever created and a null `lastError`. (#981) + + The clear list is now covered by a test that derives the expected set from `docker-compose.yml` + itself, so a forward added without its clear entry fails in CI rather than shipping inert. + ## [0.12.2] - 2026-08-01 ### Changed diff --git a/src/config/env-precedence.spec.ts b/src/config/env-precedence.spec.ts index 7dfe3472a..62f43e69a 100644 --- a/src/config/env-precedence.spec.ts +++ b/src/config/env-precedence.spec.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import * as dotenv from 'dotenv'; import { clearBlankEnv, BLANK_SHADOWED_ENV_KEYS } from './env-precedence'; +import { computeFeatureFlags } from './feature-flags'; describe('clearBlankEnv', () => { it('deletes a key whose value is empty or whitespace-only', () => { @@ -143,6 +144,26 @@ describe('blank-shadowed env keys (compose ${VAR:-} forwards the dashboard manag } }); + // #981: compose gained the AUTO_START_SESSIONS forward in v0.12.0 but the key was never added to + // BLANK_SHADOWED_ENV_KEYS, so the blank forward shadowed data/.env.generated and auto-start stayed + // off with no error — sessions sat at `disconnected` with `engineLoaded:false`. Assert the FLAG, + // not just the raw variable: that is the behaviour the operator loses. + it('keeps auto-start enabled when the forward is blank and .env.generated turns it on (#981)', () => { + const key = 'AUTO_START_SESSIONS'; + const prev = process.env[key]; + try { + withGenerated(`${key}=true`, genPath => { + process.env[key] = ''; // compose `${AUTO_START_SESSIONS:-}` with nothing set on the host + clearBlankEnv(process.env, BLANK_SHADOWED_ENV_KEYS); + dotenv.config({ path: genPath, override: false }); + expect(computeFeatureFlags(process.env).autoStartSessions).toBe(true); + }); + } finally { + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } + }); + it('lets .env.generated supply the password when the forwarded DATABASE_PASSWORD is blank', () => { withGenerated('DATABASE_PASSWORD=s3cret', genPath => { process.env[KEY] = ''; // compose `${DATABASE_PASSWORD:-}` with nothing set on the host @@ -161,3 +182,29 @@ describe('blank-shadowed env keys (compose ${VAR:-} forwards the dashboard manag }); }); }); + +// The list above is only correct while it covers EVERY `- KEY=${KEY:-}` line in the bundled compose: +// a forward without a clear entry renders blank, and dotenv's override:false then refuses to let +// .env / data/.env.generated supply a value — the operator's setting is ignored with no error. Derive +// the expectation from the compose file rather than restating a list, so a forward added without its +// clear entry fails here instead of shipping inert (#981). +describe.each(['docker-compose.yml', 'docker-compose.dev.yml'])('every blank forward in %s is cleared', file => { + const blankForwards = (): string[] => { + const compose = fs.readFileSync(path.join(__dirname, '../..', file), 'utf8'); + const found = new Set(); + for (const line of compose.split('\n')) { + const match = /^\s*-\s*([A-Z0-9_]+)=\$\{([A-Z0-9_]+):-\}\s*$/.exec(line); + if (match && match[1] === match[2]) found.add(match[1]); + } + return [...found].sort(); + }; + + // Guards the assertion below: a pattern that silently matches nothing would make it vacuously pass. + it('parses the compose forwards', () => { + expect(blankForwards()).toContain('ENGINE_TYPE'); + }); + + it('has a BLANK_SHADOWED_ENV_KEYS entry for each one', () => { + expect(blankForwards().filter(key => !BLANK_SHADOWED_ENV_KEYS.includes(key))).toEqual([]); + }); +}); diff --git a/src/config/env-precedence.ts b/src/config/env-precedence.ts index d0ee1be44..68f54d714 100644 --- a/src/config/env-precedence.ts +++ b/src/config/env-precedence.ts @@ -10,12 +10,16 @@ * provide the value, while a real (non-empty) value is preserved and keeps its top precedence. */ /** - * Keys the bundled compose forwards with `- KEY=${KEY:-}` (rendering blank when unset) AND the - * dashboard saves to `data/.env.generated`. A blank forward of one of these would shadow the - * dashboard's value, so each is cleared when blank — letting a dashboard switch (database, storage, - * redis, engine) actually apply at runtime while a real host value still pins. Only add a key that - * meets BOTH conditions (blank-forwarded by compose AND dashboard-managed); keep this list in sync - * with the `${KEY:-}` forwards in docker-compose.yml. + * Keys the bundled compose forwards with `- KEY=${KEY:-}`, which renders blank when the operator + * sets nothing. A blank forward shadows `.env` / `data/.env.generated` (both loaded with dotenv + * `override: false`), so each is cleared when blank — letting a dashboard switch (database, storage, + * redis, engine) or a hand-edited `data/.env.generated` actually apply at runtime while a real host + * value still pins. + * + * EVERY `${KEY:-}` forward in docker-compose.yml belongs here — being dashboard-managed is not a + * further condition, because `data/.env.generated` is documented as hand-editable (see load-env.ts) + * and `saveConfig` preserves keys it does not own. `env-precedence.spec.ts` derives the expected set + * from the compose file and fails when the two drift. */ export const BLANK_SHADOWED_ENV_KEYS: string[] = [ 'ENGINE_TYPE', @@ -58,6 +62,20 @@ export const BLANK_SHADOWED_ENV_KEYS: string[] = [ 'RATE_LIMIT_MEDIUM_LIMIT', 'RATE_LIMIT_LONG_TTL', 'RATE_LIMIT_LONG_LIMIT', + // Boot-time flags and limits an operator sets in .env / data/.env.generated. AUTO_START_SESSIONS + // gained its compose forward in v0.12.0 without a clear entry here, so the blank forward shadowed + // the file and auto-start silently stayed off — sessions sat at `disconnected` with no engine and + // no error to go on (#981). + 'AUTO_START_SESSIONS', + 'BODY_SIZE_LIMIT', + 'API_MASTER_KEY', + 'TRUSTED_PROXIES', + 'CSP_UPGRADE_INSECURE_REQUESTS', + // whatsapp-web.js launch knobs: the WhatsApp Web version pin, its remote HTML template, and the + // first-boot init wait raised for slow hosts. + 'WWEBJS_WEB_VERSION', + 'WWEBJS_WEB_VERSION_REMOTE_PATH', + 'WWEBJS_AUTH_TIMEOUT_MS', ]; export function clearBlankEnv(env: NodeJS.ProcessEnv, keys: string[]): void {