Both sidecars answered the same question in their own way. Which
directory does this Node child run from, when some installs put the
source tree somewhere nothing can write?
gateway/sidecar_runtime.py answers it once. Four rungs:
1. An operator override.
2. A writable source.
3. A read-only source whose baked deps match the lockfile.
4. A read-only source that must move to $HERMES_HOME/sidecars/<name>
before npm can run.
Node sets the shape of that last rung. Its ESM resolver reads
node_modules only from the directories above the importing file, and
NODE_PATH applies to CommonJS alone. Measured on Node 26: an ESM import
with NODE_PATH pointing at the packages fails, and the same import from
a directory beside them works. Both sidecars are "type": "module", so
the entry file and the packages must share a tree. A copy is the only
arrangement Node accepts.
_MIRROR_FILES is gone. It named the files to copy, so it had to name
every module the entry file imports. It was wrong twice. It listed the
deleted spectrum patch, and it omitted send-format.mjs and
stream-staleness.mjs. Both faults appear only on a read-only install.
The resolver copies the tree instead, without node_modules, and the test
compares the mirror against the source tree rather than against a second
list. A mutation that returns to a fixed list fails it.
The copy uses shutil.copy, not copy2. copy2 gives the mirror file the
mtime of the source, and a Nix store source has mtime = epoch. A
refreshed lockfile then always predates npm's install marker, and
deps_are_current() keeps stale node_modules through every upgrade,
which is the fault this resolver exists to fix. A plain copy stamps
the copy time, so a content change always postdates the previous
install. npm's hidden node_modules/.package-lock.json cannot replace
the content comparison: it is a different document, and npm matches it
semantically, not byte for byte.
WhatsApp gains what it never had: a staleness check and a refresh. Its
resolver returned any existing mirror without comparing it against the
lockfile, so an upgrade kept the old node_modules. This is a behaviour
change.
The mirrors move to $HERMES_HOME/sidecars/. The Baileys credentials live
in $HERMES_HOME/whatsapp/session, so a paired account is not affected.
`hermes doctor` reports the mirrors the old resolvers left at
$HERMES_HOME/photon/sidecar and $HERMES_HOME/scripts/whatsapp-bridge.
Nothing reads them now, and each one can hold a node_modules of some
hundred MB. --fix removes them.
_sidecar_deps_stale and deps_are_current read the same two files with
opposite missing-file answers, on purpose. Each one points at the other
and says why.
The container bakes both sidecars now. It baked Photon and left WhatsApp
to install at run time.
resolve_sidecar_dir() probes the filesystem (touch/unlink) and can mirror
sidecar files to HERMES_HOME. Doing that as a module-import side effect
meant plugin discovery, `hermes --help`, and test collection all paid a
filesystem probe (and possibly a mirror copy) just for importing the
photon adapter or CLI.
Convert _SIDECAR_DIR/_NPM_ERROR_LOG in adapter.py and cli.py to lazy
cached accessors (_sidecar_dir()/_npm_error_log()); resolution now
happens on first actual use. Existing tests that monkeypatch the
_SIDECAR_DIR module global keep working — the accessors honor a
non-None value. Adds a regression test proving import performs no
resolution.
The Photon iMessage sidecar needs node_modules under
plugins/platforms/photon/sidecar/, but hosted/managed images keep the
whole install tree under an immutable /opt/hermes — every install and
self-heal path (setup CLI, stale-deps reinstall, cold install) died on
EROFS, and hosted users have no shell to work around it.
Three-layer fix, mirroring the WhatsApp bridge resolver pattern:
1. Bake the deps into the image. The Dockerfile now runs npm ci for the
sidecar in the layer-cached dependency stage (deterministic installs
from the committed lockfile; the postinstall spectrum-ts patch runs
at build time). Hosted happy path needs no runtime install at all.
2. New sidecar_paths.resolve_sidecar_dir() decides where the sidecar
runs from: PHOTON_SIDECAR_DIR override > writable source dir (dev
installs, unchanged) > read-only dir with baked fresh deps (managed
image) > mirror to $HERMES_HOME/photon/sidecar (writable data
volume) when deps are missing or stale in a read-only tree. The
mirror refreshes changed source files on image updates while
keeping node_modules, so the existing lockfile-staleness self-heal
works there.
3. connect() can now cold-install: _start_sidecar() runs the bounded
npm ci bootstrap when node_modules is missing instead of raising
immediately, and check_requirements() reports available when a
self-install is possible (npm present + writable resolved dir) so
the gateway actually creates the adapter on hosted instances. A
failed bootstrap still raises the actionable error, which connect()
surfaces as the retryable SIDECAR_FAILED fatal state on the
dashboard.
Tests: resolver decision table (env override, in-place, mirror,
refresh, fail-open), cold-install lifecycle paths, and a Dockerfile
contract test guarding the baked-deps + no-chown invariants.
Fixes NS-606.