Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions plugins/platforms/photon/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,37 @@ def sidecar_deps_installed() -> bool:
return (_sidecar_dir() / "node_modules" / "spectrum-ts").exists()


def resolve_node_command(command: str) -> str | None:
"""Resolve ``node``/``npm`` for the sidecar, Hermes-managed install first.

``$HERMES_HOME/node`` is never on an arbitrary process's PATH — only
generated service units get it prepended — so a bare ``shutil.which()``
resolves nothing on an install whose Node *is* the managed one (the
installer provisions it precisely when system Node/npm is missing or
below the engines floor). check_requirements() then reports photon
unavailable, ``install-sidecar`` refuses to run, and the spawn falls back
to the literal ``"node"`` and dies with FileNotFoundError on every
connect. find_node_executable() checks the managed tree first and keeps
PATH as the fallback rung. Shared with cli.py so the CLI and the adapter
resolve the same interpreter.
"""
from hermes_constants import find_node_executable

return find_node_executable(command)


def sidecar_node_env(env: dict[str, str] | None = None) -> dict[str, str]:
"""Return *env* with the managed Node directories ahead on PATH.

Resolving npm by absolute path is not enough on its own: npm's launcher
runs under ``env node`` and the sidecar shells out to node too, so the
managed tree has to be reachable by name inside the child as well.
"""
from hermes_constants import with_hermes_node_path

return with_hermes_node_path(env)


def _coerce_float(value: Any, default: float) -> float:
try:
return float(value)
Expand Down Expand Up @@ -382,10 +413,13 @@ def check_requirements() -> bool:
if not HTTPX_AVAILABLE:
logger.warning("photon: httpx not installed — pip install httpx")
return False
if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"):
node_override = os.getenv("PHOTON_NODE_BIN")
if not (
shutil.which(node_override) if node_override else resolve_node_command("node")
):
logger.warning(
"photon: node binary '%s' not found on PATH",
os.getenv("PHOTON_NODE_BIN") or "node",
"photon: node binary '%s' not found",
node_override or "node",
)
return False
if not sidecar_deps_installed():
Expand All @@ -403,7 +437,7 @@ def check_requirements() -> bool:
# 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()):
if bool(resolve_node_command("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
Expand Down Expand Up @@ -458,9 +492,9 @@ def _reinstall_sidecar_deps() -> None:
Best-effort — a failure here just leaves the (stale) deps in place and the
normal ``_start_sidecar`` readiness check reports the real error.
"""
npm = shutil.which("npm")
npm = resolve_node_command("npm")
if not npm:
logger.warning("[photon] cannot reinstall stale sidecar deps: npm not on PATH")
logger.warning("[photon] cannot reinstall stale sidecar deps: no usable npm")
return
# Windows: suppress the console flash these short-lived npm runs would
# otherwise pop (0 elsewhere). Same helper as the sidecar spawn below.
Expand All @@ -474,6 +508,7 @@ def _reinstall_sidecar_deps() -> None:
text=True, encoding="utf-8", errors="replace",
check=False,
timeout=_NPM_REINSTALL_TIMEOUT,
env=sidecar_node_env(),
creationflags=windows_hide_flags(),
)
if result.returncode != 0:
Expand All @@ -487,6 +522,7 @@ def _reinstall_sidecar_deps() -> None:
text=True, encoding="utf-8", errors="replace",
check=False,
timeout=_NPM_REINSTALL_TIMEOUT,
env=sidecar_node_env(),
creationflags=windows_hide_flags(),
)
except subprocess.TimeoutExpired:
Expand Down Expand Up @@ -712,7 +748,9 @@ def __init__(self, config: PlatformConfig):
self._autostart_sidecar = str(
os.getenv("PHOTON_SIDECAR_AUTOSTART", "true")
).lower() not in ("0", "false", "no")
self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node"
self._node_bin = (
os.getenv("PHOTON_NODE_BIN") or resolve_node_command("node") or "node"
)

# Presence watchdog. spectrum-ts only reconnects when its inbound
# iterator throws or ends; a half-open ("zombie") gRPC socket makes the
Expand Down Expand Up @@ -1571,7 +1609,7 @@ async def _start_sidecar(self) -> None:
await asyncio.to_thread(_reinstall_sidecar_deps)
await self._reap_stale_sidecar()

env = os.environ.copy()
env = sidecar_node_env()
env["PHOTON_PROJECT_ID"] = self._project_id
env["PHOTON_PROJECT_SECRET"] = self._project_secret
env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port)
Expand Down
18 changes: 12 additions & 6 deletions plugins/platforms/photon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,19 @@
import argparse
import getpass
import os
import shutil
import subprocess
import sys
from pathlib import Path

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 .adapter import (
_NPM_ERROR_LOG_MAX_CHARS,
resolve_node_command,
sidecar_deps_installed,
sidecar_node_env,
)
from .sidecar_paths import resolve_sidecar_dir

# Writable sidecar runtime dir (mirrors to HERMES_HOME on immutable
Expand Down Expand Up @@ -385,7 +389,7 @@ def _cmd_status(_args: argparse.Namespace) -> int:
# callback is the only sink that sees credential-derived strings, so
# cli.py keeps zero taint flow according to CodeQL.
photon_auth.print_credential_summary(print)
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
node_bin = os.getenv("PHOTON_NODE_BIN") or resolve_node_command("node")
sidecar_installed = sidecar_deps_installed()
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
Expand Down Expand Up @@ -442,10 +446,10 @@ def _cmd_telemetry(args: argparse.Namespace) -> int:


def _install_sidecar() -> int:
npm = shutil.which("npm") or "npm"
if not shutil.which(npm):
npm = resolve_node_command("npm")
if not npm:
print(
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
"No usable npm found. Install Node.js 18+ (https://nodejs.org/) "
"and re-run.",
file=sys.stderr,
)
Expand All @@ -468,6 +472,7 @@ def _install_sidecar() -> int:
check=False,
stderr=subprocess.PIPE,
text=True,
env=sidecar_node_env(),
)
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
Expand All @@ -479,6 +484,7 @@ def _install_sidecar() -> int:
check=False,
stderr=subprocess.PIPE,
text=True,
env=sidecar_node_env(),
)
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
Expand Down
4 changes: 3 additions & 1 deletion tests/plugins/platforms/photon/test_inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,12 @@ def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:


def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
# If no node binary on PATH the adapter should refuse to start.
# With no node binary anywhere — PATH or the Hermes-managed tree — the
# adapter should refuse to start.
from plugins.platforms.photon import adapter as adapter_mod

monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None)
monkeypatch.setattr(adapter_mod, "resolve_node_command", lambda _name: None)
assert adapter_mod.check_requirements() is False


Expand Down
Loading
Loading