Changed
-
The live engine is reachable through its own narrow port instead of through the session lifecycle
owner. Ten feature services (contacts, groups, labels, channels, calls, profile, catalog, status,
and both message services) injected the whole 2.9k-lineSessionServicepurely to reach its private
enginesmap, which coupled every one of them to start/stop/delete/reconnect semantics they never
call. The map now lives inEngineRegistry, exported from the (already global)EngineModule, so
those services depend on "give me the running engine for this session" and eight feature modules no
longer importSessionModuleat all.SessionServiceremains the only writer, and the services
that genuinely drive the lifecycle (MessageServiceforfindOne/edit recording,InfraController
for orphan reaping) still hold it deliberately. No API change: each call site keeps its own error, so
contacts/labels/groups/channels/calls/profile still answer 400Session is not started,
catalog/status still answer 404not found or not connected, and the two message services keep their
distinct wording.openapi.jsonis unchanged, byte for byte.The engine-identity rule that guards every lifecycle path — a late callback from a superseded engine
must never mutate a session that now belongs to a different one, or to none — was open-coded at
around twenty call sites asisLiveEngine(id, e) && engines.delete(id). It is nowisLive/
deleteIfLiveon the registry, written once. -
Six self-contained concerns were lifted out of
SessionService. Each was previously reachable
only by driving the full session lifecycle, so the trickiest logic in the file had the least direct
coverage. The@lid→phone read-through cache becameSessionLidResolver(and took an@Optional
constructor dependency with it); the reconnect backoff decision became a puredecideReconnect()
with injected clock and jitter, leaving the service to apply only the effects; the liveness watchdog
becameSessionLivenessWatchdog, which owns its interval and failure counter and reports back
through a singleonDeadcallback; the per-message serialization chain became a general
KeyedMutationQueue; every path that turns an engine message callback into a persisted row —
live inbound, own-send echo, ack reconciliation, revoke, reactions, edits and history backfill —
becameMessageProjector, which owns the one mutation chain that IS the per-message ordering
guarantee; and the transient failure reason behindlastErrorbecameSessionErrorStore, the one
map that eight lifecycle paths write and exactly one reader — the session read model — consumes.Behaviour is unchanged. The existing session specs still pass, edited only mechanically: they
register the new collaborators as real providers (not mocks) and reach their state instead of the
service's own fields — no assertion was changed or removed. The split adds 69 tests over branches
that previously needed hours of uptime or live engine callbacks to reach: the FIFO eviction that
bounds the lid cache, the stability reset that stops a long-lived
session slowly wedgingFAILEDacross unrelated transient drops, the loop-alert re-arm after a
stable stretch, the non-finite-delay fallback that keeps an operator typo from becoming a relaunch
storm, the watchdog's stale-result guard for an engine superseded mid-probe, and the mutation-chain
reclamation that stops the map growing once per message touched.The three message-persist paths (live inbound, own-send echo, history backfill) also stop
re-deriving their shared row mapping:buildMessageMetadata()now states the one deliberate
difference between them (inbound trusts the engine's media field; the two paths known to lose media
synthesize the omitted marker that keeps a row from rendering as an empty bubble and dropping out of
the by-type stats), andstorableWaMessageId()makes the empty-sentinel chokepoint real rather than
a comment repeated at each call site.initializeEngineitself drops from 801 lines to 324. Nearly all of it was engine-callback bodies
inlined into one object literal, which hid the wiring — which events exist, and in what order —
under the handling; the five largest (inbound message, own-send echo, ack, ready, revoke) are now
named methods and the function reads as the event table it is. Pure code motion: each method takes
the same captured(id, engine)the closure did, so the stale-generation identity guards are
unchanged. -
The session lifecycle no longer imports the whatsapp-web.js adapter to size a timeout. The
deadlineSessionServiceracesengine.initialize()against is engine-agnostic — it applies to
Baileys sessions too — but it was derived inline fromresolveAuthTimeoutMs(), which the adapter
owned, so the lifecycle owner depended on one specific engine's module for a value it applies to
all of them. Both the env parse and the derivation now live inengine/engine-init-timeout.ts, and
the adapter re-exportsresolveAuthTimeoutMsfor the callers that legitimately reach it through
the engine they are configuring.Pure code motion — the derived deadline is identical, and both
whatsapp-web-js.adapter.spec.tsandsession.service.spec.tspass with no edits. The wwjs-named
WWEBJS_AUTH_TIMEOUT_MSstill feeds the shared floor, which is documented rather than changed: it
can only ever raise the deadline above 60s, never lower it, so a Baileys session gets a more
generous window and never a shorter one. Splitting the two engines' windows needs its own env var
and is a behaviour change, not a move. -
data/.env.generatedhas one declared location instead of three derived ones.InfraController
re-builtpath.resolve(process.cwd(), 'data', '.env.generated')at each of its three readers — the
built-in-flag fallback behind the Docker probe, the config form's hydrate, and the merge base the
save path then writes back over — which made the file an undeclared dependency shared between
reading infrastructure status, rendering the form, and persisting credentials. Those are otherwise
independent concerns that touch none of the same state, so moving the file would have been a
three-site edit with nothing to catch a missed one.generated-env.tsnow owns the path and the
parse.Pure code motion:
infra.controller.spec.tspasses with no edits, and the path is still resolved
per call rather than captured at import.database/load-cli-env.tsdeliberately keeps its own copy
— it resolves the same filename against an injectedcwdso it stays testable withoutchdir, and
folding it in would remove that seam.
Fixed
-
Installed plugin code now lands in the same tree as the plugin registry.
PLUGINS_DIRdefaulted
to./pluginswhilePluginStorageServicekept the registry — status, operator config, secrets,
enabledByOperator— under<dataDir>/plugins. The two halves of one install therefore defaulted to
two different trees: the loader scanned a directory that did not exist and reported "Loaded 0
plugins" while the registry still listed every plugin as installed. Under Docker it was worse than
confusing, because/app/datais the mounted volume and./pluginsis not: an installed plugin's
code went into the ephemeral container layer and was destroyed by the nextdocker compose up -d,
while its config and secrets survived in the registry — a plugin that vanished on every recreate
with nothing in the logs pointing at why. The default is now<dataDir>/plugins, derived from the
same constant the registry path is built from so the two cannot drift apart again.Existing installs keep working: when
PLUGINS_DIRis unset, the old./pluginsis still scanned as
a compatibility fallback, in addition to the configured directory (a host part-way through
migrating keeps both halves; the configured copy loads first and wins a duplicate id). The fallback
is keyed on actually finding a plugin package — a non-dot subdirectory with amanifest.json— not
on the directory existing, because<dataDir>/plugins/<id>doubles as each plugin'sctx.storage
dir and is routinely full of directories that hold only state. SettingPLUGINS_DIRdisables the
fallback outright: an operator who named the directory has said where plugins live. -
A restart no longer overwrites the sessions an operator bound a plugin instance to. The boot
scope reconciler re-derived a plugin'sactiveSessionsfrom each instance row, which silently
discarded an explicitPUT /api/plugins/{id}/sessions—activeSessionsis restored from
registry.jsonand already encodes the outcome of every prior decision, including that one. It also
re-bound the plugin to the row's scope even when that session had since been deleted, leaving the
plugin activated for a session id nothing will ever match. The boot path is now additive: it only
ever adds the row's scope and removes nothing. Retiring'*'on a concrete activation remains a
provisioning-time decision, where the operator is actually narrowing the plugin.A concrete scope that matches no session row is now also logged once at boot
(scope_binding_session_missing). Such an instance receives no events while every signal an operator
can read stays reassuring — the row saysenabled, the plugin's status saysenabled, hooks are
registered andhealthCheckis green — so this line is the only place that inertness surfaces. It is
diagnostic only and never alters the binding: the id may legitimately come back via an import or a
re-provision. -
Plugin health no longer reports a hook error from a worker that already died. The last
hook-handler error a sandboxed plugin reported is operator context on
GET /api/plugins/{id}/health, and it is scoped to one worker generation — the field's contract was
"a fresh enable starts from a clean slate". It was cleared only ondisablePlugin, but a worker
crash and a failed enable both end a generation without going through disable. The replacement
worker therefore inherited the dead one's error, and health reported it as current: an operator
restarting a plugin to clear a fault saw the same fault reported against the healthy worker that
replaced it.The record is now cleared where a generation starts, so it holds for every way one can end rather
than for the single path that happened to be handled. -
A rolled-back
POST /api/infra/import-datano longer denies the engines it already stopped. The
orphan pre-flight runs before the transaction opens, andstopOrphans: truereally destroys those
engines there — a teardown the rollback cannot undo. Both rollback branches nevertheless returned a
hardcodedrestartRequired: falsewith three empty orphan arrays, so an operator who hit a per-row
warning read "nothing was stopped, no restart needed" while their sessions were in fact down. They
now report what actually happened, exactly as the success path already did.restartRequiredon that path narrows to the one thing a rollback cannot undo: a failed teardown,
which may have left a Chromium/socket alive. A cleanly stopped orphan leaves its session row intact
(restart it withPOST /sessions/{id}/start), and an engineforceleft running was never orphaned
after all, because the data that would have orphaned it was not replaced. The response shape is
unchanged andopenapi.jsonis untouched — only the values were wrong. -
A missing dashboard asset returns 404 instead of the SPA shell.
ServeStaticModule's built-in
fallback answered every unmatched GET withindex.html, so a mistyped or stale<script src>
came back200 text/htmland the browser reported a JavaScript syntax error from parsing the HTML
shell — pointing at the wrong file and hiding a broken build.main.tsalready serves dashboard
documents (it injects the per-response CSP nonce, so it must own them) and is correctly narrow:
it skips/assets, and only answers extensionless paths or explicittext/htmlnavigations. The
module's own catch-all is now disabled so that handler is the single owner. Client-side routes are
unaffected.This also repairs a deployment shape that was broken outright: when the install path contains a
dot-segment (~/.openwa, a checkout under~/.cache, aTMPDIRinside a dotdir), the built-in
fallback sent the index by absolute path and Express'ssendrefuses dot-segments under its
defaultdotfiles: 'ignore'— so every client-side route 404'd while/and the hashed assets
kept working. The e2e lock now runs its whole matrix against both path shapes, with a fixture
guard asserting the two really differ (os.tmpdir()is not reliably dot-free, and using it
blindly collapsed both cases onto one shape and hid exactly this bug). -
The non-root smoke test can actually be run.
scripts/smoke-test-non-root.shcarried a UTF-8
BOM ahead of its#!/bin/sh, and neither it norscripts/smoke-test-docker-proxy.shhad the
executable bit — so the./scripts/…invocation both of them document failed outright. The image
runs its process as a non-root user by dropping privileges in the entrypoint rather than by a
USERdirective, which is a runtime property no static check can see, and this script is the only
thing that verifies it. CI's shellcheck step now covers every script inscripts/instead of three
of them; the BOM is exactly what it reports as SC1082, so the narrow scope is what let it survive. -
An e2e run no longer rewrites the developer's
data/.api-key. The bootstrap key file is written
on first boot (which every e2e run is, having no keys yet) and unlinked when that key is revoked or
deleted. Its path was a module const evaluated at import fromprocess.cwd(), so nothing could
redirect it — an e2e boot whose setup already redirects both databases still reached straight into
the real repo-rootdata/.api-key. The four file operations now have one owner
(bootstrap-key-file.ts) that resolves the path per call and honours aBOOTSTRAP_KEY_FILE
override, which the e2e setup points at the same throwaway temp dir as the databases. The file is an
operator convenience only — never read for seeding or authentication — so this changes nothing about
how a key is issued or validated. -
Opening or closing the QR modal no longer rebuilds the session start/stop/logout handlers.
applySessionResponsereadqrDatato decide whether the modal it was clearing belonged to the
session being updated, soqrData?.sessionIdhad to sit in its dependency array — and all three
lifecycle handlers hold that callback. The functional updater form removes the read and the
dependency with it, and is the more correct shape besides: it sees the CURRENT modal rather than
whichever one was captured when the callback was last built. -
A message for a chat the sidebar does not yet have refetches the chat list once, not twice.
The sidebar updater calledloadChats()from inside asetChatsupdater; React double-invokes
updaters underStrictMode(whichmain.tsxenables), so the refetch fired twice per such message
in development. The decision now lives in a reducer that REPORTSneedsSidebarRefetchand the caller
fires the refetch once, outside the updater — a shape that cannot hide a side effect the way an
inline updater could. Both sidebar reducers moved toutils/chatList.ts, which also makes the
reorder rules, the location-label substitution and the #583 LID-echo suppression reachable from a
test for the first time. -
A stray directory under
data/pluginsno longer reads as a plugin fault. Anything in there
without amanifest.jsonis skipped and logged, once per directory on every boot. The wording was a
bare "Plugin<name>missing manifest.json", which describes an internal failure rather than a
directory the loader simply does not recognise — an operator reporting an unrelated session problem
pasted two of these lines as evidence for it. The message now names what was skipped and what to do
about it. Themanifest_missingaction key is unchanged, so existing log filters still match. -
.env.examplerecords whenAUTO_START_SESSIONSbegan taking effect under Docker Compose. The
bundleddocker-compose.ymldid not forward the variable into the container before v0.12.0, and
nothing else could supply it — there is noenv_file:entry,.envis not mounted, and the build
context excludes it. Setting the flag on an earlier version therefore did nothing at all, and
authenticated sessions stayeddisconnectedafter every restart with no indication why. The
forwarding itself was fixed in v0.12.0; this only stops the sample config from implying the value was
always live.
Removed
scripts/openwa.sh, an orchestration helper superseded by the in-process Docker orchestration on
/api/infra. It had no reference frompackage.json, any workflow, the Dockerfile, either compose
file, or the documentation.
Security
-
⚠️ Four unfixed Chromium CVEs are accepted in the
linux/arm64image.CVE-2026-16804,
-16805,-16806and-16807affect thechromium,chromium-commonandchromium-sandbox
packages. They are arm64-only by construction: Chrome for Testing publishes no linux-arm64 build,
so the amd64 image uses CfT while arm64 installs Debian's chromium — the amd64 image is unaffected.
There is no fixed package to upgrade to: the image carries150.0.7871.181-1~deb12u1, and the
fixed151.0.7922.47-1exists only in Debian sid — bookworm and trixie are both still on150.x,
so neither a rebuild nor a move to trixie clears it.Two of the four (
-16804,-16807) are sandbox escapes, and the image already runs Chromium with
--no-sandbox(the container is the confinement boundary:cap_drop ALL,no-new-privileges,
read-only rootfs), so they buy an attacker nothing. The other two are use-after-free / arbitrary
code execution in Blink, and those are not neutralised by that: Chromium renders sender-
controlled content, so a crafted message is a plausible path to code execution as theopenwauser
inside the arm64 container. This is accepted so the release can ship while Debian has no fixed
build — not because it is harmless. Operators running arm64 who cannot accept this should stay on
their current image until the entry is removed. The four are recorded in.trivyignorewith the
removal condition: drop them once bookworm shipschromium >= 151.0.7922.47. -
A sandboxed plugin can no longer serve the gateway's search queries without declaring a permission
for it.ctx.registerSearchProvideris installed unconditionally in every worker context, and a
plugin declares itself a provider by sendingsearch-provider-registerover IPC — a path that never
passes through the capability router gatingctx.messages/ctx.net/ctx.engine. Nothing between
that declaration and theSearchProviderRegistryconsulted the manifest, and under the shipped default
SEARCH_PROVIDER=autoa registered provider is also made active, supersedingbuiltin-fts. A
plugin that declared no permissions at all could therefore see every queryGET /api/searchserves.Registration now requires the new
search:providepermission. A plugin without it is refused before
the provider reaches the registry, the active provider is left untouched, and the host logs one
warning (sandbox_search_provider_denied) — bounded to a single line per enable, because
WorkerSearchRegistryposts the declaration only on the plugin's first call. The check is warned
rather than silently dropped (as the ingress-subscribe guard does) because there is no manifest
searcharray, so no load-time validation can catch it and an operator would otherwise get no signal.
hasPermissionis a required field ofRegisterPluginSearchProviderDeps, so the compiler — not
reviewer discipline — is what keeps a future call site from re-opening the gap.Action required for search-provider plugins: add
"permissions": ["search:provide"]to the
manifest. Plugins that do not provide search are unaffected, as are all first-party plugins. No
gateway payload changes.