diff --git a/Dockerfile b/Dockerfile index 42870d737761..5fc6b7cdeaa8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -200,6 +200,22 @@ RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \ done && \ npm cache clean --force +# ---------- Photon iMessage sidecar deps (baked, NS-606) ---------- +# The photon plugin's Node sidecar needs its own node_modules +# (spectrum-ts). The install tree is immutable at runtime, so a lazy +# `npm ci` on first connect would hit EROFS — bake the deps here instead +# (deterministic installs, NS-559). The patch script is copied alongside +# the manifests because package.json's postinstall runs it, which also +# means the spectrum-ts patch is applied at build time. Layer-cached: +# only re-runs when the sidecar manifests/patch change. +COPY plugins/platforms/photon/sidecar/package.json \ + plugins/platforms/photon/sidecar/package-lock.json \ + plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \ + plugins/platforms/photon/sidecar/ +RUN cd plugins/platforms/photon/sidecar && \ + npm ci --no-audit --fetch-retries=5 && \ + npm cache clean --force + # ---------- Layer-cached Python dependency install ---------- # Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel # download + native-extension compile layer is cached unless those inputs diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 597327f1e385..6a4910ca0f4b 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -184,7 +184,13 @@ _DEDUP_WINDOW_SECONDS = 48 * 3600 _FFFC_WAIT_SECONDS = 15.0 # Timeout for waiting on an attachment after a U+FFFC placeholder. -_SIDECAR_DIR = Path(__file__).parent / "sidecar" +# Resolved once at import: the installed plugin tree when writable (dev +# installs), or a mirror on the durable data volume when the install tree +# is immutable and the baked deps are missing/stale (hosted images, NS-606). +# See sidecar_paths.resolve_sidecar_dir for the full decision table. +from .sidecar_paths import dir_writable as _dir_writable, resolve_sidecar_dir + +_SIDECAR_DIR = resolve_sidecar_dir() _NPM_ERROR_LOG = _SIDECAR_DIR / ".photon-npm-error.log" _NPM_ERROR_LOG_MAX_CHARS = 300 @@ -367,6 +373,16 @@ def check_requirements() -> bool: # prevents a false positive where an empty/broken node_modules/ dir # causes check_requirements() to return True while the sidecar crashes # at runtime with an unrelated-looking missing-module error. + # + # NS-606: if we can self-install at connect time — npm on PATH and + # the (resolved, possibly mirrored) sidecar dir is writable — report + # available so the gateway creates the adapter and ``_start_sidecar`` + # cold-installs from the committed lockfile (on hosted images the + # user has no CLI to run `hermes photon setup`, so the connect path + # must self-heal). Otherwise keep returning False so + # `hermes setup` / status surface the missing-deps state. + if bool(shutil.which("npm")) and _dir_writable(_SIDECAR_DIR): + return True # DEBUG (not WARNING): this is the normal pre-setup state. # check_fn() is called from multiple hot paths in the core # (load_gateway_config, hermes status, GET /api/status polling) — @@ -1519,10 +1535,27 @@ class PhotonAdapter(BasePlatformAdapter): async def _start_sidecar(self) -> None: if not sidecar_deps_installed(): - raise RuntimeError( - f"Photon sidecar deps not installed. Run: " - f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)" + # Cold install (NS-606): on hosted/managed images the install + # tree is immutable and the user has no CLI to run + # `hermes photon setup`, so the connect path must be able to + # bootstrap the deps itself. _SIDECAR_DIR has already been + # resolved to a writable location (or mirrored to the data + # volume) by sidecar_paths.resolve_sidecar_dir; `npm ci` off + # the committed lockfile is deterministic and bounded by + # _NPM_REINSTALL_TIMEOUT. Uses sidecar_deps_installed() — not a + # bare node_modules/ existence check — so a partial/aborted npm + # install (empty node_modules/) also triggers the reinstall. + logger.info( + "[photon] sidecar deps not installed; installing into %s", + _SIDECAR_DIR, ) + await asyncio.to_thread(_reinstall_sidecar_deps) + if not sidecar_deps_installed(): + raise RuntimeError( + f"Photon sidecar deps could not be installed into " + f"{_SIDECAR_DIR} (see log for the npm error). " + f"Run: cd {_SIDECAR_DIR} && npm ci (or `hermes photon setup`)" + ) # A `hermes update` that bumps the spectrum-ts pin rewrites # package-lock.json but never reinstalls node_modules, so the sidecar # spawns against stale deps and dies on every reconnect (the v8 patch diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 871e43a595ad..da64912685d9 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -30,8 +30,11 @@ from hermes_cli.colors import Colors, color from . import auth as photon_auth from .adapter import _NPM_ERROR_LOG_MAX_CHARS, sidecar_deps_installed +from .sidecar_paths import resolve_sidecar_dir -_SIDECAR_DIR = Path(__file__).parent / "sidecar" +# Writable sidecar runtime dir (mirrors to HERMES_HOME on immutable +# installs — NS-606). All npm/setup work happens here. +_SIDECAR_DIR = resolve_sidecar_dir() # Written on npm failure so check_requirements() can surface the root cause # when called later (gateway start, hermes status). Cleared on success. _NPM_ERROR_LOG = _SIDECAR_DIR / ".photon-npm-error.log" diff --git a/plugins/platforms/photon/sidecar_paths.py b/plugins/platforms/photon/sidecar_paths.py new file mode 100644 index 000000000000..69f86a145846 --- /dev/null +++ b/plugins/platforms/photon/sidecar_paths.py @@ -0,0 +1,141 @@ +""" +Resolve where the Photon sidecar runs from and where its Node deps live. + +The sidecar source ships inside the installed plugin tree +(``plugins/platforms/photon/sidecar/``). On dev/source installs that tree is +writable and everything — ``npm ci``, the spectrum patch, the sidecar itself — +happens in place. Hosted/managed images instead keep the whole install tree +under an immutable ``/opt/hermes`` (read-only for the hermes user), which +broke every install/self-heal path with EROFS (NS-606). + +Resolution order (mirrors ``resolve_whatsapp_bridge_dir`` for the Baileys +bridge, which hit the same wall): + +1. ``PHOTON_SIDECAR_DIR`` env override — operator escape hatch, used as-is. +2. Source dir writable → run in place (dev installs, unchanged behavior). +3. Source dir read-only but ``node_modules`` is baked and current → run in + place. This is the managed-image happy path: the Dockerfile bakes the + sidecar deps with ``npm ci`` at build time (deterministic installs, + NS-559), so no runtime install is ever needed. +4. Source dir read-only and deps missing or stale → mirror the sidecar + source files to ``$HERMES_HOME/photon/sidecar`` (the durable data volume, + e.g. ``/opt/data`` on hosted) and return that. The caller's normal + install/self-heal machinery then works there because it is writable. + +The mirror is refreshed on every resolve: when an image update changes a +sidecar source file, the changed file is re-copied (content compare, not +mtime) while ``node_modules`` is left in place — the adapter's existing +lockfile-vs-install-marker staleness check then triggers the ``npm ci`` +self-heal inside the mirror. + +This module is import-light on purpose: both ``adapter.py`` (gateway) and +``cli.py`` (``hermes photon ...``) use it. +""" + +from __future__ import annotations + +import filecmp +import logging +import os +import shutil +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +SOURCE_SIDECAR_DIR = Path(__file__).parent / "sidecar" + +# The files that define the sidecar. Mirrored into the writable runtime dir +# when the install tree is read-only. node_modules is deliberately absent — +# it is either baked (managed image) or installed by npm in the mirror. +_MIRROR_FILES = ( + "index.mjs", + "package.json", + "package-lock.json", + "patch-spectrum-mixed-attachments.mjs", +) + + +def dir_writable(path: Path) -> bool: + """True when we can create files in ``path`` (probe-based, not stat). + + A stat-mode check lies on containers (root-squash, read-only bind + mounts), so probe with a real create+unlink like the WhatsApp bridge + resolver does. + """ + probe = path / ".hermes-write-probe" + try: + probe.touch() + probe.unlink() + return True + except OSError: + return False + + +# Backwards-friendly private alias for module-internal use. +_dir_writable = dir_writable + + +def _lock_newer_than_install(sidecar_dir: Path) -> bool: + """True when the committed lockfile postdates npm's install marker. + + Same signal as ``adapter._sidecar_deps_stale`` — duplicated here (three + lines) rather than imported so this module stays import-light for the + CLI. Returns False on any stat failure so an odd filesystem never forces + the mirror path. + """ + lockfile = sidecar_dir / "package-lock.json" + marker = sidecar_dir / "node_modules" / ".package-lock.json" + try: + return lockfile.stat().st_mtime > marker.stat().st_mtime + except OSError: + return False + + +def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path: + """Return the directory the sidecar should run from (see module doc). + + ``source_dir`` defaults to the installed plugin tree; tests and callers + that monkeypatch the adapter's ``_SIDECAR_DIR`` pass it through so the + override keeps working. + """ + source = Path(source_dir) if source_dir is not None else SOURCE_SIDECAR_DIR + + override = os.getenv("PHOTON_SIDECAR_DIR") + if override: + return Path(override) + + if _dir_writable(source): + return source + + # Read-only install tree (hosted/managed image). If the image baked the + # deps at build time and they match the lockfile, run in place — the + # sidecar itself never writes inside its own directory. + if (source / "node_modules").exists() and not _lock_newer_than_install(source): + return source + + # Deps missing or stale inside a read-only tree: mirror to the durable + # data volume so the normal install/self-heal machinery has somewhere + # writable to work. + from hermes_constants import get_hermes_home + + mirror = get_hermes_home() / "photon" / "sidecar" + try: + mirror.mkdir(parents=True, exist_ok=True) + for name in _MIRROR_FILES: + src = source / name + if not src.exists(): + continue + dst = mirror / name + if not dst.exists() or not filecmp.cmp(str(src), str(dst), shallow=False): + shutil.copy2(str(src), str(dst)) + return mirror + except OSError as exc: + logger.warning( + "[photon] install tree is read-only and mirroring the sidecar " + "to %s failed (%s) — falling back to the read-only source dir; " + "dependency installs will not be possible", + mirror, + exc, + ) + return source diff --git a/tests/plugins/platforms/photon/test_check_requirements_risks.py b/tests/plugins/platforms/photon/test_check_requirements_risks.py index 5bcd38921881..6b62d9cfc031 100644 --- a/tests/plugins/platforms/photon/test_check_requirements_risks.py +++ b/tests/plugins/platforms/photon/test_check_requirements_risks.py @@ -105,6 +105,9 @@ def test_fix_logs_debug_with_sidecar_path_when_node_modules_missing( WARNING here would spam logs on every probe when photon is not configured.""" monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True) monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path) + # NS-606: disable the connect-time self-heal branch (npm + writable dir + # would short-circuit to True) so this exercises the no-self-install path. + monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False) # node_modules intentionally NOT created — simulates failed npm install with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"): @@ -148,6 +151,9 @@ def test_risk2_fix_empty_node_modules_no_longer_passes_guard( monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True) monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path) monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log") + # NS-606: disable the connect-time self-heal branch so the guard itself + # (empty node_modules must not read as installed) is what's under test. + monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False) (tmp_path / "node_modules").mkdir() # empty — spectrum-ts absent # Fix verified: False instead of the old false-positive True. @@ -264,6 +270,8 @@ def test_fix_risk3_check_requirements_surfaces_npm_error_in_debug_log( monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True) monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path) monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", error_log) + # NS-606: disable self-heal so the npm-error surfacing branch is reached. + monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False) # node_modules NOT created with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"): diff --git a/tests/plugins/platforms/photon/test_npm_error_log_regression.py b/tests/plugins/platforms/photon/test_npm_error_log_regression.py index 6a280c6226b8..da6e1a18e776 100644 --- a/tests/plugins/platforms/photon/test_npm_error_log_regression.py +++ b/tests/plugins/platforms/photon/test_npm_error_log_regression.py @@ -118,6 +118,8 @@ def test_regression_oserror_on_log_read_does_not_propagate( monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True) monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path) + # NS-606: disable self-heal so the log-read branch is reached. + monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False) monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", _UnreadablePath(tmp_path / ".err")) result = adapter_mod.check_requirements() @@ -264,6 +266,8 @@ def test_regression_debug_log_emitted_even_without_error_log( monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True) monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path) monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log") + # NS-606: disable self-heal so the debug-log branch is reached. + monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False) # node_modules NOT created, error log NOT created with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"): diff --git a/tests/plugins/platforms/photon/test_sidecar_lifecycle.py b/tests/plugins/platforms/photon/test_sidecar_lifecycle.py index 717efddeb7a3..aee104918ede 100644 --- a/tests/plugins/platforms/photon/test_sidecar_lifecycle.py +++ b/tests/plugins/platforms/photon/test_sidecar_lifecycle.py @@ -191,20 +191,106 @@ async def test_start_sidecar_spawns_with_stdin_pipe( @pytest.mark.asyncio -async def test_start_sidecar_rejects_empty_node_modules( +async def test_start_sidecar_cold_installs_missing_deps( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """Missing node_modules must trigger an install attempt, not a bare raise. + + NS-606: on hosted images the user cannot shell in to run + `hermes photon setup`, so the connect path is the only chance to + bootstrap the sidecar deps (into the writable resolved dir). + """ + adapter = _make_adapter(monkeypatch) + monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path) + + installs: List[str] = [] + + def _fake_install() -> None: + installs.append("ran") + (tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True) + + monkeypatch.setattr(photon_adapter, "_reinstall_sidecar_deps", _fake_install) + + async def _no_reap() -> None: + pass + + monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap) + + class _PatchResult: + returncode = 0 + stdout = "" + stderr = "" + + monkeypatch.setattr( + photon_adapter.subprocess, "run", lambda *a, **k: _PatchResult() + ) + + class _FakeProc: + pid = 999 + stdout = None + stdin = None + + @staticmethod + def poll() -> None: + return None + + monkeypatch.setattr( + photon_adapter.subprocess, "Popen", lambda *a, **k: _FakeProc() + ) + + class _HealthyClient(_ProbeClient): + async def post(self, *a: Any, **k: Any) -> Any: + class _Resp: + status_code = 200 + + return _Resp() + + monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient) + + await adapter._start_sidecar() + + assert installs == ["ran"] + + +@pytest.mark.asyncio +async def test_start_sidecar_reinstalls_empty_node_modules( monkeypatch: pytest.MonkeyPatch, tmp_path ) -> None: """A partial/aborted npm install leaves an empty node_modules/ behind. - _start_sidecar() must reject it the same way check_requirements() does - (both go through sidecar_deps_installed()) instead of spawning a sidecar - that immediately crashes on a missing spectrum-ts module. + _start_sidecar() must treat it as not-installed the same way + check_requirements() does (both go through sidecar_deps_installed()) + instead of spawning a sidecar that immediately crashes on a missing + spectrum-ts module. With the NS-606 cold-install path, "treat as + not-installed" means: attempt a reinstall, and raise the actionable + error if that still doesn't produce spectrum-ts. """ adapter = _make_adapter(monkeypatch) (tmp_path / "node_modules").mkdir() # empty — spectrum-ts absent monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path) - with pytest.raises(RuntimeError, match="not installed"): + installs: List[str] = [] + monkeypatch.setattr( + photon_adapter, "_reinstall_sidecar_deps", lambda: installs.append("ran") + ) + + with pytest.raises(RuntimeError, match="could not be installed"): + await adapter._start_sidecar() + + assert installs == ["ran"] + + +@pytest.mark.asyncio +async def test_start_sidecar_raises_when_cold_install_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """If the bootstrap install can't produce node_modules, fail with the + actionable error (surfaced as SIDECAR_FAILED by connect()).""" + adapter = _make_adapter(monkeypatch) + monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path) + monkeypatch.setattr(photon_adapter, "_reinstall_sidecar_deps", lambda: None) + + with pytest.raises(RuntimeError, match="could not be installed"): await adapter._start_sidecar() diff --git a/tests/plugins/platforms/photon/test_sidecar_paths.py b/tests/plugins/platforms/photon/test_sidecar_paths.py new file mode 100644 index 000000000000..d9791e134f02 --- /dev/null +++ b/tests/plugins/platforms/photon/test_sidecar_paths.py @@ -0,0 +1,149 @@ +"""Tests for the Photon sidecar directory resolver (NS-606). + +Hosted/managed images keep the plugin tree under an immutable +``/opt/hermes``; ``resolve_sidecar_dir`` must run in place when the deps are +baked and current, and mirror the sidecar to the writable ``HERMES_HOME`` +volume when a runtime install is unavoidable. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +import plugins.platforms.photon.sidecar_paths as sidecar_paths + + +def _seed_source(source: Path, *, with_node_modules: bool = False) -> None: + source.mkdir(parents=True, exist_ok=True) + for name in sidecar_paths._MIRROR_FILES: + (source / name).write_text(f"// {name}\n", encoding="utf-8") + if with_node_modules: + (source / "node_modules").mkdir() + (source / "node_modules" / ".package-lock.json").write_text( + "{}", encoding="utf-8" + ) + + +def _freeze_writability(monkeypatch, *, writable: bool) -> None: + monkeypatch.setattr(sidecar_paths, "_dir_writable", lambda _p: writable) + + +def test_env_override_wins(tmp_path, monkeypatch) -> None: + override = tmp_path / "custom" + monkeypatch.setenv("PHOTON_SIDECAR_DIR", str(override)) + assert sidecar_paths.resolve_sidecar_dir(tmp_path / "src") == override + + +def test_writable_source_runs_in_place(tmp_path, monkeypatch) -> None: + """Dev installs: writable tree keeps today's behavior exactly.""" + monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False) + source = tmp_path / "src" + _seed_source(source) + _freeze_writability(monkeypatch, writable=True) + assert sidecar_paths.resolve_sidecar_dir(source) == source + + +def test_readonly_source_with_baked_fresh_deps_runs_in_place( + tmp_path, monkeypatch +) -> None: + """Managed-image happy path: deps baked at build time, no mirror needed.""" + monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False) + source = tmp_path / "src" + _seed_source(source, with_node_modules=True) + # Marker newer than lockfile == fresh install. + lock = source / "package-lock.json" + marker = source / "node_modules" / ".package-lock.json" + os.utime(lock, (1000.0, 1000.0)) + os.utime(marker, (2000.0, 2000.0)) + _freeze_writability(monkeypatch, writable=False) + assert sidecar_paths.resolve_sidecar_dir(source) == source + + +def test_readonly_source_missing_deps_mirrors_to_hermes_home( + tmp_path, monkeypatch +) -> None: + """Immutable tree without baked deps must relocate to the data volume.""" + monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False) + home = tmp_path / "home" + monkeypatch.setenv("HERMES_HOME", str(home)) + source = tmp_path / "src" + _seed_source(source) # no node_modules + _freeze_writability(monkeypatch, writable=False) + + resolved = sidecar_paths.resolve_sidecar_dir(source) + + assert resolved == home / "photon" / "sidecar" + for name in sidecar_paths._MIRROR_FILES: + assert (resolved / name).read_text(encoding="utf-8") == f"// {name}\n" + + +def test_readonly_source_stale_baked_deps_mirrors(tmp_path, monkeypatch) -> None: + """Baked deps older than the lockfile (image skew) must not run in place.""" + monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False) + home = tmp_path / "home" + monkeypatch.setenv("HERMES_HOME", str(home)) + source = tmp_path / "src" + _seed_source(source, with_node_modules=True) + lock = source / "package-lock.json" + marker = source / "node_modules" / ".package-lock.json" + os.utime(lock, (2000.0, 2000.0)) + os.utime(marker, (1000.0, 1000.0)) + _freeze_writability(monkeypatch, writable=False) + + assert sidecar_paths.resolve_sidecar_dir(source) == home / "photon" / "sidecar" + + +def test_mirror_refresh_updates_changed_files_and_keeps_node_modules( + tmp_path, monkeypatch +) -> None: + """Image update changes index.mjs → re-copied; installed deps survive.""" + monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False) + home = tmp_path / "home" + monkeypatch.setenv("HERMES_HOME", str(home)) + source = tmp_path / "src" + _seed_source(source) + _freeze_writability(monkeypatch, writable=False) + + mirror = sidecar_paths.resolve_sidecar_dir(source) + # Simulate a completed npm install in the mirror. + (mirror / "node_modules").mkdir() + (mirror / "node_modules" / "installed.txt").write_text("x", encoding="utf-8") + + # Image update rewrites a source file. + (source / "index.mjs").write_text("// index.mjs v2\n", encoding="utf-8") + + resolved = sidecar_paths.resolve_sidecar_dir(source) + + assert resolved == mirror + assert (mirror / "index.mjs").read_text(encoding="utf-8") == "// index.mjs v2\n" + assert (mirror / "node_modules" / "installed.txt").exists() + + +def test_mirror_failure_falls_back_to_source(tmp_path, monkeypatch) -> None: + """If HERMES_HOME is unusable too, return the source dir (fail-open).""" + monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False) + # Point HERMES_HOME at a path under a file so mkdir fails. + blocker = tmp_path / "blocker" + blocker.write_text("", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(blocker / "home")) + source = tmp_path / "src" + _seed_source(source) + _freeze_writability(monkeypatch, writable=False) + + assert sidecar_paths.resolve_sidecar_dir(source) == source + + +def test_dir_writable_probe(tmp_path) -> None: + assert sidecar_paths.dir_writable(tmp_path) is True + ro = tmp_path / "ro" + ro.mkdir() + ro.chmod(0o555) + try: + if os.geteuid() == 0: # pragma: no cover - root ignores perms + pytest.skip("root bypasses directory permissions") + assert sidecar_paths.dir_writable(ro) is False + finally: + ro.chmod(0o755) diff --git a/tests/tools/test_dockerfile_immutable_install.py b/tests/tools/test_dockerfile_immutable_install.py index c712bb63ceab..49b3e1826784 100644 --- a/tests/tools/test_dockerfile_immutable_install.py +++ b/tests/tools/test_dockerfile_immutable_install.py @@ -109,3 +109,25 @@ def test_dockerfile_redirects_lazy_installs_to_durable_target() -> None: "lazy-packages must be in the per-boot chown subdir list so it stays " "hermes-owned" ) + + +def test_dockerfile_bakes_photon_sidecar_deps() -> None: + """The Photon sidecar's node_modules must be baked at build time (NS-606). + + The install tree is immutable at runtime, so a lazy `npm ci` on first + connect would hit EROFS. Baking the deps (from the committed lockfile, + which also runs the spectrum-ts postinstall patch) makes the hosted + happy path install-free. Guards the contract between the Dockerfile + and plugins/platforms/photon/sidecar_paths.resolve_sidecar_dir, which + runs in place only when the baked deps exist and match the lockfile. + """ + text = _dockerfile_text() + + assert "plugins/platforms/photon/sidecar/package-lock.json" in text + assert re.search( + r"RUN cd plugins/platforms/photon/sidecar && \\\n\s+npm ci", text + ), "sidecar deps must be installed with `npm ci` (deterministic, runs postinstall patch)" + # Immutability contract: never chown the sidecar tree to the runtime user. + assert not re.search( + r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/plugins", text + )