github rmyndharis/OpenWA v0.12.2

latest releases: v0.23.5, v0.23.4, v0.23.3...
one month ago

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-line SessionService purely to reach its private
    engines map, which coupled every one of them to start/stop/delete/reconnect semantics they never
    call. The map now lives in EngineRegistry, 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 import SessionModule at all. SessionService remains the only writer, and the services
    that genuinely drive the lifecycle (MessageService for findOne/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 400 Session is not started,
    catalog/status still answer 404 not found or not connected, and the two message services keep their
    distinct wording. openapi.json is 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 as isLiveEngine(id, e) && engines.delete(id). It is now isLive /
    deleteIfLive on 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 became SessionLidResolver (and took an @Optional
    constructor dependency with it); the reconnect backoff decision became a pure decideReconnect()
    with injected clock and jitter, leaving the service to apply only the effects; the liveness watchdog
    became SessionLivenessWatchdog, which owns its interval and failure counter and reports back
    through a single onDead callback; 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 —
    became MessageProjector, which owns the one mutation chain that IS the per-message ordering
    guarantee; and the transient failure reason behind lastError became SessionErrorStore, 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 wedging FAILED across 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), and storableWaMessageId() makes the empty-sentinel chokepoint real rather than
    a comment repeated at each call site.

    initializeEngine itself 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
    deadline SessionService races engine.initialize() against is engine-agnostic — it applies to
    Baileys sessions too — but it was derived inline from resolveAuthTimeoutMs(), 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 in engine/engine-init-timeout.ts, and
    the adapter re-exports resolveAuthTimeoutMs for 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.ts and session.service.spec.ts pass with no edits. The wwjs-named
    WWEBJS_AUTH_TIMEOUT_MS still 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.generated has one declared location instead of three derived ones. InfraController
    re-built path.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.ts now owns the path and the
    parse.

    Pure code motion: infra.controller.spec.ts passes with no edits, and the path is still resolved
    per call rather than captured at import. database/load-cli-env.ts deliberately keeps its own copy
    — it resolves the same filename against an injected cwd so it stays testable without chdir, and
    folding it in would remove that seam.

Fixed

  • Installed plugin code now lands in the same tree as the plugin registry. PLUGINS_DIR defaulted
    to ./plugins while PluginStorageService kept 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/data is the mounted volume and ./plugins is not: an installed plugin's
    code went into the ephemeral container layer and was destroyed by the next docker 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_DIR is unset, the old ./plugins is 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 a manifest.json — not
    on the directory existing, because <dataDir>/plugins/<id> doubles as each plugin's ctx.storage
    dir and is routinely full of directories that hold only state. Setting PLUGINS_DIR disables 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's activeSessions from each instance row, which silently
    discarded an explicit PUT /api/plugins/{id}/sessionsactiveSessions is restored from
    registry.json and 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 says enabled, the plugin's status says enabled, hooks are
    registered and healthCheck is 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 on disablePlugin, 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-data no longer denies the engines it already stopped. The
    orphan pre-flight runs before the transaction opens, and stopOrphans: true really destroys those
    engines there — a teardown the rollback cannot undo. Both rollback branches nevertheless returned a
    hardcoded restartRequired: false with 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.

    restartRequired on 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 with POST /sessions/{id}/start), and an engine force left running was never orphaned
    after all, because the data that would have orphaned it was not replaced. The response shape is
    unchanged and openapi.json is 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 with index.html, so a mistyped or stale <script src>
    came back 200 text/html and the browser reported a JavaScript syntax error from parsing the HTML
    shell — pointing at the wrong file and hiding a broken build. main.ts already 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 explicit text/html navigations. 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, a TMPDIR inside a dotdir), the built-in
    fallback sent the index by absolute path and Express's send refuses dot-segments under its
    default dotfiles: '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.sh carried a UTF-8
    BOM ahead of its #!/bin/sh, and neither it nor scripts/smoke-test-docker-proxy.sh had 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
    USER directive, 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 in scripts/ 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 from process.cwd(), so nothing could
    redirect it — an e2e boot whose setup already redirects both databases still reached straight into
    the real repo-root data/.api-key. The four file operations now have one owner
    (bootstrap-key-file.ts) that resolves the path per call and honours a BOOTSTRAP_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.
    applySessionResponse read qrData to decide whether the modal it was clearing belonged to the
    session being updated, so qrData?.sessionId had 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 called loadChats() from inside a setChats updater; React double-invokes
    updaters under StrictMode (which main.tsx enables), so the refetch fired twice per such message
    in development. The decision now lives in a reducer that REPORTS needsSidebarRefetch and 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 to utils/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/plugins no longer reads as a plugin fault. Anything in there
    without a manifest.json is 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. The manifest_missing action key is unchanged, so existing log filters still match.

  • .env.example records when AUTO_START_SESSIONS began taking effect under Docker Compose. The
    bundled docker-compose.yml did not forward the variable into the container before v0.12.0, and
    nothing else could supply it — there is no env_file: entry, .env is not mounted, and the build
    context excludes it. Setting the flag on an earlier version therefore did nothing at all, and
    authenticated sessions stayed disconnected after 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 from package.json, any workflow, the Dockerfile, either compose
    file, or the documentation.

Security

  • ⚠️ Four unfixed Chromium CVEs are accepted in the linux/arm64 image. CVE-2026-16804,
    -16805, -16806 and -16807 affect the chromium, chromium-common and chromium-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 carries 150.0.7871.181-1~deb12u1, and the
    fixed 151.0.7922.47-1 exists only in Debian sid — bookworm and trixie are both still on 150.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 the openwa user
    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 .trivyignore with the
    removal condition: drop them once bookworm ships chromium >= 151.0.7922.47.

  • A sandboxed plugin can no longer serve the gateway's search queries without declaring a permission
    for it.
    ctx.registerSearchProvider is installed unconditionally in every worker context, and a
    plugin declares itself a provider by sending search-provider-register over IPC — a path that never
    passes through the capability router gating ctx.messages / ctx.net / ctx.engine. Nothing between
    that declaration and the SearchProviderRegistry consulted the manifest, and under the shipped default
    SEARCH_PROVIDER=auto a registered provider is also made active, superseding builtin-fts. A
    plugin that declared no permissions at all could therefore see every query GET /api/search serves.

    Registration now requires the new search:provide permission. 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
    WorkerSearchRegistry posts 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
    search array, so no load-time validation can catch it and an operator would otherwise get no signal.
    hasPermission is a required field of RegisterPluginSearchProviderDeps, 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.

Don't miss a new OpenWA release

NewReleases is sending notifications on new releases.