Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/config/env-precedence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
Expand All @@ -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<string>();
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([]);
});
});
30 changes: 24 additions & 6 deletions src/config/env-precedence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 {
Expand Down
Loading