pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.
Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).
The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.
User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
The image's root s6 scandir (/run/service) is watched by the PID-1
s6-svscan, so every slot in it runs under a root s6-supervise that
executes the slot's run/finish/log scripts as root. 02-reconcile-profiles
chowned that directory to hermes so per-profile gateway slots could be
registered at runtime, and 015-supervise-perms chowned the static
services' supervise/ and event/ trees to hermes. Both handed the
unprivileged hermes user control over what the root supervisor runs
and writes.
- Per-profile gateway slots move to /run/hermes-service, watched by a
nested s6-svscan that runs as hermes (new s6-rc longrun
profile-gateways). Gateways, their loggers and their supervisors now
all run as hermes; the rendered run/log scripts already skip the
privilege drop when non-root.
- 02-reconcile-profiles creates that scandir hermes-owned (refusing a
symlink) and no longer touches /run/service.
- 015-supervise-perms keeps the static services' supervise/ trees
root-owned and only grants group hermes read on status/lock and write
on the control FIFO, leaving event/ as s6 creates it.
- hermes doctor probes the static services in the root scandir via a
new s6_service_up() helper; S6_DYNAMIC_SCANDIR now points at the
gateway scandir and S6_STATIC_SCANDIR names the root one.
- Docker integration tests, docs and the s6 skill follow the new
gateway slot path.
One built-in skill now covers Word, PowerPoint and Excel: SKILL.md
holds the shared documents-environment block, PDF export and the
cross-format pitfalls once, and routes to references/word.md,
references/powerpoint.md and references/excel.md for each format's
flags, procedure and traps. The scripts and their tests move unchanged
into one scripts/ and tests/ directory (62 tests pass). Every skill,
doc and tool message that named the old three now names
microsoft-office.
The terminal tool's env snapshot is captured by `bash -l`, and Debian's
/etc/profile resets PATH for every login shell, so the snapshot pinned the
reset PATH over the spawn env for every later command. In the Docker image
that dropped /opt/hermes/bin and bare `hermes` exited 127 (#56634).
The snapshot bootstrap now re-appends, in parent order, every spawn-env PATH
dir the login shell dropped (not only Hermes' bin dir: the gateway's pm
runtime dirs and `docker run -e PATH=` additions too). They go after the
login PATH, so a command that resolved before resolves to the same binary
and the user's ~/.profile additions keep precedence.
The image also ships /etc/profile.d/hermes.sh, which re-adds the ENV PATH
dirs for login shells the snapshot never covers: the terminal tool's
background `bash -lic`, `docker exec <c> bash -l` and ssh.
Found on Hermes Cloud staging.
Hosted Docker instances could not run the hermes-agent skill's tmux spawning
recipe (no tmux) or create Office/PDF files: the docx/xlsx/powerpoint/pdf
skills said `pip install ...`, but the image's sealed venv has no pip and the
root fs resets on restart.
- Dockerfile: tmux in the runtime apt layer (+3 MB); bake a new [documents]
extra (python-docx, openpyxl, python-pptx, reportlab, pypdf) into the
sealed venv (+14 MB compressed, ~+38 MB on disk).
- pyproject/uv.lock: the [documents] extra, exact pins, outside [all];
pm/extras ANCHORS gets its import anchors.
- docx/xlsx/powerpoint/pdf (+ optional excel-author/pptx-author): use
`python` when it already imports the libs, otherwise a persistent side
venv at $HERMES_HOME/environments/documents (recreated when its Python is
gone); pdf script hints and the OCR reference point there, never at pip
into Hermes's interpreter.
- hermes-agent skill: send text and Enter as separate tmux keystrokes (one
burst lands as a newline, verified live), plus a no-tmux fallback via
terminal(background=true, pty=true) + process_manage write.
Found on Hermes Cloud staging.
The webapp_build stage copied all of apps/ before running the recipe, so
any Desktop source change reran npm ci. build-webapp.mjs gains
`--deps-only` (prepareWebappWorkspace), and the stage runs it after
copying only the manifests and the recipe. The build after `COPY apps/`
reuses that install through the node-deps receipt.
- pm: AgentBrowser class + lock entry gone; Chromium default=True and its env
exports only PLAYWRIGHT_BROWSERS_PATH (Playwright launches without an
executable path still need it). A recorded or passed --without agent-browser
maps to chromium (pm.defaults.RETIRED_DEFAULTS), and pm gc forgets the stale
agent-browser fact so its store entry is reclaimed.
- install.sh --skip-browser/--no-playwright and install.ps1 -SkipBrowser pass
--without chromium; undo hint is hermes pm install chromium.
- Docker bakes /etc/hermes/chromium-executable-path; stage2-hook validates it
and exports nothing; the sandbox-desktop image drops the agent-browser npm
install and keeps its Playwright Chromium.
The frontend build stage copies scripts/build/node-deps.mjs on its own
and runs it for ui-tui and web. node-deps.mjs now imports
electron-runtime.mjs, so every image build failed with
ERR_MODULE_NOT_FOUND before npm ran. Copy both files. The Dockerfile is
the only place that ships node-deps.mjs without the rest of
scripts/build.
The image build ran pm.build_env as root before HERMES_HOME was set, so uv's
cache went to /root/.hermes/cache/uv. At runtime pm.packages.uv_cache_dir()
returns $HERMES_HOME/cache/uv (an empty volume path) and only seeds it from
/opt/hermes/uv-cache, which the image never created. Any new PM generation
that includes python-olm (for example a plugin install) therefore rebuilt it
from source: python-olm has no cp313/cp314 wheel.
Pass --cache /opt/hermes/uv-cache so the build writes the cache to the seed
path. uv_cache_dir() already merges it into the volume cache on first use.
Measured with --cpus=2, same pm.build_env command, --extra matrix, fresh
output dir and the runtime cache dir:
before: 50.0 s (Building python-olm)
after: 2.7 s
The seed adds 414 MB (du -sh /opt/hermes/uv-cache) to the image. I did not
test a plugin install end to end, and I did not test arm64.
The image bakes eight extras into /opt/hermes/.venv but never recorded
them as PM's dependency selection. The first generation a container built
(a lazy extra such as fal, a plugin's dependencies) started from an empty
selection, so it carried only what was requested and dropped aiohttp, the
messaging SDKs and mcp. After the next restart the API server, platform
adapters and MCP servers failed to start.
- pm/build_env.py, Dockerfile: --record-selection writes the frozen
build's --extra list to the store facts as the venv selection, as a
bundle build does. enabled-features.json is not written, so the frozen
lazy-install policy stays out of Docker.
- pm/recovery.py: stage2's refresh restores extras the image ships that a
volume's recorded selection lacks, through the normal explicit sync. On
failure the image venv boots and the missing extras stay recorded for
the next attempt. A malformed recorded fact is refused, as
venv_is_current does.
- pm/install.py: share the PEP 685 extra-name key between
_still_declared and the boot reconciliation.
Fixes#135329
The extra removal left CI, the Docker image and the nix package still
requesting `hindsight`. Once the extra is gone, `--extra hindsight` and
extraDependencyGroups = [ "hindsight" ] ask for something that no longer
exists. Drop them the same way 73c598e319 originally did: remove it from
the CI extras lists, the Docker sealed-venv build and the nix default
groups, and point the nix examples/check at honcho. Also remove the
stray blank line left in the exclude-newer table.
uv and node already come from pm's sha-verified lock; the base image was
the last input pinned by tag only. Pin both FROMs to the multi-arch index
digest (sha256:e2d08da6f42ef4b09b165d55528a12727aeed8240dc9edf888e3ec07e10ef9da) so every arch resolves the same reviewed base.
The image refused every lazy install (HERMES_DISABLE_LAZY_INSTALLS=1), so
edge-tts and the other opt-in SDKs could never be installed at runtime. PM
never writes the sealed /opt/hermes/.venv: it builds a generation under
$HERMES_HOME/installs and commits it in facts.json there, which already
survives container recreates and image updates. Drop the refusal.
Surviving updates means a new image boots under a selection resolved against
the previous image's lock. refresh_dependencies() re-resolves the recorded
extras and plugins against the current inputs; if that fails (offline), it
deselects the generation so the image's own environment boots, keeping the
extras recorded for the next boot or install. stage2 runs it as hermes before
any service starts, then collects generations nothing selects any more, since
nothing else collects them automatically and each one is a full venv.
The lock pins both as required packages, but the image took them from apt,
so `hermes pm doctor` reported them missing and `hermes doctor` escalated to
"install out of sync ... rebuild the artifact" on every container. Ensure them
through PM like uv/chromium/npm, drop the apt packages, and link ffmpeg,
ffprobe (shipped beside it) and rg into /usr/local/bin for `docker exec`.
The published image had no Xvnc/Xfce because nothing set the Dockerfile's
HERMES_BOT_DESKTOP argument, and a hosted instance (unprivileged, no sudo,
sealed /opt/hermes) cannot install at run time. The image layer is the only
delivery path.
- docker.yml: variant axis [slim, desktop]. :latest / :main / :v* stay the
image they are today; :latest-desktop / :main-desktop / :v*-desktop carry
the packages plus Playwright's headed Chromium. Slim owns the build cache
scope; one manifest per variant so a desktop publish failure never skips
slim's :latest.
- Dockerfile / stage2-hook.sh: XDG_RUNTIME_DIR=/tmp/hermes-runtime seeded
0700 as hermes (containers have no logind; the $HOME/.cache fallback was
the shared /opt/data volume), refused when foreign-owned; deterministic
Chromium discovery exporting the headless shell for ordinary browsing.
- bot_desktop: memory gate reads the cgroup working set (usage minus
inactive_file) so it cannot tighten over uptime and refuse to restart a
screen idle-stop just stopped; installable() gives three distinct dead-end
messages instead of a sudo line nobody there can run; env_for_agent
replaces a headless-shell pin so agent and dock share one Chromium.
Squash of IAvecilla/hermes-agent:bot-desktop-cloud-image (#112381, 13
commits), which GitHub auto-closed when its base branch merged as #108914.
Review fixes from pefontana (cache scope, per-variant merge, red browser
test) are included.
Co-authored-by: pefontana <pefontana@users.noreply.github.com>
The catalog plugin declares hindsight-client in its own plugin.yaml and the
plugin installer resolves it (into HERMES_LAZY_INSTALL_TARGET on the sealed
Docker image), so the pyproject extra, its exclude-newer entry and every
consumer that pre-installed it — the Dockerfile, the nix full package /
module examples / check override, and the CI `uv sync` matrices — stop naming
it. uv.lock regenerated with `uv lock` (only the hindsight-client package and
the extra leave the lock; the exclude-newer block is re-sorted by uv).
- voice_mode: `_import_audio` asks `pm.ensure_import("audio-io")` before importing
sounddevice, so `/voice on` turns the feature on (or reports exactly why PM
refused: lazy installs off, platform gate, restart needed) instead of printing a
`python -c "from pm import sync_venv…"` one-liner for the user to run.
- pm.plugins_state: `read_home_selection` and `dependency_homes` are the public
readers; memory_provider_migration and plugins_cmd stop importing underscored names.
- pm.store: the `sha256_file` shim is gone; the authority test asserts the Store has no
file hash of its own rather than patching one.
- tests/pm/conftest: `isolated_machine_home` is autouse (Path.home, HOME, USERPROFILE,
HERMES_HOME all under tmp_path); `@pytest.mark.real_machine_home` opts a module out
— the four suites that spawn children with a home they build themselves.
- activate / activate.ps1 / Dockerfile followed hermes_cli.runtime_paths to
pm.environments (the earlier sweep only covered .py).
- pm.registry loads builtins through importlib so a test patching `pm.packages` in
sys.modules still registers the security packages.
`import pm.ensure` bound the submodule onto the package, shadowing the facade's
`pm.ensure()` function for every later caller in the process (photon's sidecar
start hit `'module' object is not callable`). The module is pm.install now; the
function keeps its name. The facade resolves through `__import__` rather than
`importlib.import_module` so a test that patches import_module globally does not
break attribute access on pm.
tools/lazy_deps.py returns to the 16-line stop_for_relaunch shim the branch wrote
(an origin/main merge had replaced it with main's 775-line implementation); the
project-metadata tests follow. update_cmd re-exports the four old_updater_deps
names the shim tests resolve through hermes_cli.update_cmd.
The official image runs the gateway as an unprivileged user with no sudo,
so the pane's install path can never work there; the only way onto a hosted
instance is packages in the image. One conditional layer (~550 MB:
TigerVNC, Xfce components, headed chromium), off by default so the
published image is unchanged; nothing starts at boot.
Hosted/Docker images lock /opt/hermes/.venv, so --install-deps writing
site-packages fails with Permission denied and the adapter never starts.
Route Google Chat through lazy_deps (HERMES_LAZY_INSTALL_TARGET) and bake
the extra into the published image so a configured gateway can connect.
Competing installers and checkout-local venv assumptions bypassed PM
selection, install consent, and generation lifetimes. Route consumers
through PM and installation-bound launchers. Refresh source launchers
before obsolete Python entries can be collected.
Remove Node, browser, and CUA acquisition engines, obsolete venv-holder
handling, detached sync, and unused PM APIs. Keep historical updater
exports inert and preserve external tool ownership and native integration.
Share product freshness and prepared inputs across builders. Align plugin
admission, Docker provisioning, setup instructions, and behavioral tests.
Verified targeted Python and JavaScript tests, desktop and web typechecks,
scoped lint, real product builds, and the Docker frontend smoke test.
The missed post-setup test cleanup is included and verified.
Native Windows/macOS execution, full Rust compilation, and the complete
repository suite remain unverified. Historical compatibility requirements
were preserved and extended, not fully rescanned.
Build TUI, web, desktop UI and runnable agent products from explicit
prepared inputs. Keep dependency preparation separate from distribution
packaging, with PM and native builds sharing uv environment construction.
Docker copies compiled frontend products instead of build dependencies.
Nix retains uv2nix environments and consumes shared assembly through store
references. Native desktop and Termux use the same launcher and frontend
contracts. Preserve the independent PM runtime and source imports from
arbitrary working directories.
Keep failed frontend builds from replacing the previous product, reject
source/output overlap, and bound dependency-process output draining.
Include hermes_wisdom in the Nix wheel: real CLI smoke tests exposed its
missing package declaration on the base revision too.
Verified focused Python and JavaScript suites, Docker build/runtime checks,
Nix desktop and CLI/ACP checks, standalone TUI and packaged Electron PTY,
and real full-Chromium interaction. Native signed installers, Android device
installation and the full repository suite remain CI verification.
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.
Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.
Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.
Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.
Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.
Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
Full Chromium serves both headed and headless sessions. The separate
shell duplicates the browser payload and is not needed for either mode.
Remove the shell from PM and Docker. Select the managed Chromium
executable for agent-browser and the full Chromium channel for direct
Playwright callers. Route setup through PM and remove retired packages
from cached bundle stores without changing the user's tool store.
Update signing, architecture checks, launch probes and install guidance.
Leave llama packages and Docker archive cleanup unchanged.
Verification:
- Real agent-browser navigation, clicks, DOM reads and screenshots pass
in headed and headless modes with the same Chromium executable.
- The direct Playwright doctor probe passes.
- Focused Python and desktop packaging tests pass, as do six Docker
checks and both real-browser task-scroll tests.
- The built linux/amd64 image is 1.393 GB compressed, 223.6 MB smaller.
- The broader PM suite and two unrelated setup tests still fail.
Those failures reproduce on unchanged HEAD.
- Five updated eval scripts parse; their full scenarios were not run.
Generate frontend icons from the shared source artwork during each build.
Keep the locked icon dependencies out of the application runtime.
Nix needs setuptools and CFFI to build the locked python-olm source.
Its offline npm manifest must omit semver overrides because direct
package specs become local tarball paths. Preserve the repository
manifest and lockfile.
Docker must use the PM interpreter rather than a second Python under
/root. Install the application into its venv explicitly, use the image
compiler, and make package facts readable by the runtime user.
Verified:
- nix build .#desktop and frontend-icons/package-contents/cli-commands
- Packaged Electron PTY spawn and libolm encryption roundtrip
- ARM Linux and macOS desktop evaluation, not native builds
- Docker linux/amd64 build and 8 focused runtime tests
The full test suite was not run. Leave llama integration unchanged.
Use the native path separator for explicit test files. Copy the stdlib runtime owners before Docker invokes PM. Preserve the isolated home in config tests and patch current receipt/backup owners. Remove stale Arabic navigation keys introduced by the merge.
The pm stage copies only pm/ then runs python3 -m pm.cli install uv
chromium chromium-headless-shell. pm/packages.py's uv_cache_dir()
lazily imports get_default_hermes_root() from hermes_constants
(unconditional, not ImportError-guarded like ensure.py's
hermes_cli.config) — with only pm/ on the path the stage died with
'No module named hermes_constants'. hermes_constants is stdlib-only;
COPY it next to pm (reproduced the failure shape locally: pm.cli
imports + install fine with pm/+HERMES_RUNTIME_DIR, fails the moment
uv_cache_dir resolves).
Introduce the pm store: a unified, hash-verified package store that
replaces lazy_deps and the old installer's ad-hoc tool downloads.
Store tools are provisioned on PATH (ffmpeg, node/npm via pinned uv),
with a resumable 8-way downloader, verify() returning failure reasons,
and adopt() made EPERM-safe. chromium ships in the payload for every
target. The 3600-line install.sh is replaced by a staged bootstrapper
(heavy deps are pm's job after this); setup-hermes.sh, Dockerfile and
nix pin tables are rewired onto the store. Old install-script tests,
lazy_deps/managed_uv/build_info, and the ps1/bash installer test
batteries are removed with the machinery they tested.
Rebuilt from ethie/pm onto upstream/main (ac6c8028e0) after the
utf-8-sig sweep. 16 hot files (main also churned them) hand-merged:
platform adapters, main.py, electron/main.ts, tui_gateway/server.py,
cua_backend, installer-tests workflow, install.sh (full rewrite),
setup-hermes.sh, plugins doc.
Cherry-picked core of #92545: the image build writes a versioned,
non-secret marker (/etc/hermes/image-provenance.json) outside both the
bind-mountable checkout and the HERMES_HOME volume, and
hermes_cli/image_provenance.py reads it fail-closed — absence means
'not image-managed', any present-but-malformed marker still means
image-managed (an integrity defect is never permission to mutate the
image in place).
(#91277 Phase 3; salvaged from #92545 by @andrexibiza — marker bake +
reader only, the scoped carve-out.)
secure_parent_dir() is called before credential file writes to harden
the parent dir. Its existing safety check refuses only paths with fewer
than 3 path parts, but /opt/hermes is exactly 3 parts, so it passes and
gets chmod'd to 0700. UID 10000 (hermes) cannot then traverse the
install dir and every new exec fails with 'Permission denied' until
manual chmod 0755 /opt/hermes.
This change:
- Adds an explicit refusal for /opt/hermes parents in secure_parent_dir()
- Adds chmod 0755 /opt/hermes to the Dockerfile install step next to
the existing bin chmod, so the dir starts traversable
Reproducer: any auth write to a file directly under /opt/hermes
(e.g. auth.json when HERMES_HOME resolves there). Observed in
production 2026-07-06 and 2026-08-22. See #25821 for context.
Fixes#25821 follow-up.
Two breaks from moving node_source to node:26, both proven against the
real image rather than inferred:
1. `COPY .../node_modules/corepack` failed with "not found". Node
unbundled corepack upstream, so node:26 ships only `npm` in
/usr/local/lib/node_modules (verified: `ls` in the pinned image lists
`npm` alone). Nothing in this repo needs it — no package.json declares
a `packageManager` and no build step shells out to yarn or pnpm — so
the COPY and its symlink are removed rather than replaced.
2. Hidden behind that failure: node 26's binary links against
`libatomic.so.1`, which node 22's did not, and bare debian:13.4
doesn't ship it. Without it every `node` invocation in the image dies
with "error while loading shared libraries: libatomic.so.1". Added
`libatomic1` to the existing apt layer, which runs well before the
node COPY so layer ordering and caching are unchanged.
Verified with a minimal probe image (debian:13.4 + the same two COPY
lines): node v26.5.1, npm 11.17.0, npx 11.17.0, uv 0.11.6 all execute.
Hermes now pins its toolchain to Node 26 everywhere. Every path that
installs, accepts, heals, or upgrades a Node runtime moves from the old
22-default / `^20.19 || >=22.12` floor to a single rule: Node >=26.
Installers:
- scripts/install.sh — NODE_VERSION=26; node_satisfies_build() collapses
the two-branch Vite floor to `major >= 26`; user-facing messages updated.
- scripts/install.ps1 — $NodeVersion=26; Test-NodeVersionOk likewise;
winget fallback switches OpenJS.NodeJS.LTS -> OpenJS.NodeJS (26 is
Current, not LTS — the LTS manifest would reinstall a too-old Node).
- Dockerfile — node_source stage node:22-bookworm-slim -> node:26 (digest
pinned, amd64 sha256:9e6f...bf73).
- nix/ was already on nodejs_26 (lib.nix, npm-12-0-2.nix); the checks.nix
wrapper check ratchets from `>= 20` to `>= 26`.
Heal/upgrade paths:
- scripts/lib/node-bootstrap.sh — HERMES_NODE_TARGET_MAJOR default 22->26
and HERMES_NODE_MIN_VERSION default 20->26, so heal_managed_node,
_nb_install_bundled_node, and the fnm/proto/nvm/brew rungs all target 26
and stop accepting an on-PATH Node below it. Both remain env-overridable.
- hermes_constants.py — _HERMES_NODE_TARGET_MAJOR fallback 22->26, which
drives the Windows heal path's latest-v26.x download.
Version gates:
- package.json engines.node >=20 -> >=26; apps/desktop engines
`^20.19.0 || >=22.12.0` -> `>=26.0.0`.
- CI setup-node: all five workflows 22 -> 26.
- Docs describing Hermes's own toolchain updated (windows-native, docker,
acp, nix-setup, contributing). Skill docs describing third-party tools'
own requirements are untouched.
Termux still installs via `pkg install nodejs` best-effort (nodejs.org
ships no Android tarballs); that path was never version-gated.
Verified: bash -n on both shell scripts, PowerShell AST parse of
install.ps1, latest-v26.x index resolves (node-v26.5.1), and the install
test suite — 18 tests across the 5 install/runtime test files — passes.
Replace the bare /init ENTRYPOINT with entrypoint-dispatch.sh: exec
/init + main-wrapper when the image owns PID 1, fall back to a direct
stage2 bootstrap (with the s6 helper PATH restored) on wrapped runtimes
where s6-overlay-suexec would abort with 'can only run as pid 1'
(Fly Machines, docker run --init, podman/FreeBSD setups).
Cherry-picked from PR #43763 by @konsisumer, conflicts with current
main resolved (tests/test_dockerfile_tini_compat_shim.py was moved to
tests/docker/, container_boot argv tests were reshaped upstream).
Fixes#38349
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.
Compile and checksum-pin SQLite 3.53.4 in the published image, preserve Hermes' required SQLite features, and assert the final Python linkage plus FTS5 trigram behavior during image builds.\n\nMake doctor remediation install-aware so Docker users pull and recreate every Hermes container instead of running the inapplicable git updater.\n\nFixes #70480