Skip to content
Merged
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
33 changes: 26 additions & 7 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1454,18 +1454,29 @@ def run_doctor(args):
# npm audit for all Node.js packages
_npm_bin = _safe_which("npm")
if _npm_bin:
npm_dirs = [
(PROJECT_ROOT, "Browser tools (agent-browser)"),
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
# Each entry: (cwd, label, extra_audit_args)
# PROJECT_ROOT is audited with --workspaces=false so that the apps/*
# glob (which pulls in Electron, node-pty, etc.) is never resolved
# for a routine security check. The web and ui-tui workspaces are
# audited separately via --workspace flags. See #38772.
npm_audit_targets = [
(PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]),
(PROJECT_ROOT, "web workspace", ["--workspace", "web"]),
(PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge", []),
]
for npm_dir, label in npm_dirs:
if not (npm_dir / "node_modules").exists():
for npm_dir, label, audit_extra in npm_audit_targets:
# For workspace-scoped audits run from PROJECT_ROOT the
# node_modules check must use the workspace root; standalone dirs
# (whatsapp-bridge) check their own node_modules.
check_dir = PROJECT_ROOT if audit_extra else npm_dir
if not (check_dir / "node_modules").exists():
continue
try:
# Use resolved absolute path so Windows can execute
# npm.cmd (CreateProcessW can't run bare .cmd names).
audit_result = subprocess.run(
[_npm_bin, "audit", "--json"],
[_npm_bin, "audit", "--json", *audit_extra],
cwd=str(npm_dir),
capture_output=True, text=True, timeout=30,
)
Expand All @@ -1476,12 +1487,20 @@ def run_doctor(args):
high = vuln_count.get("high", 0)
moderate = vuln_count.get("moderate", 0)
total = critical + high + moderate
# Determine a scoped fix command for the remediation hint.
if audit_extra and audit_extra[0] == "--workspace":
fix_scope = " ".join(audit_extra)
fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
elif audit_extra == ["--workspaces=false"]:
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
else:
fix_cmd = f"cd {npm_dir} && npm audit fix"
if total == 0:
check_ok(f"{label} deps", "(no known vulnerabilities)")
elif critical > 0 or high > 0:
check_warn(
f"{label} deps",
f"({critical} critical, {high} high, {moderate} moderate — run: cd {npm_dir} && npm audit fix)"
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
)
issues.append(
f"{label} has {total} npm "
Expand Down
16 changes: 11 additions & 5 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1570,7 +1570,9 @@ def _node_bin(bin: str) -> str:
if not os.environ.get("HERMES_QUIET"):
print("Installing TUI dependencies…")
npm_cwd = _workspace_root(tui_dir)
npm_workspace_args: tuple[str, ...] = ()
# --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty).
# See #38772.
npm_workspace_args: tuple[str, ...] = ("--workspace", "ui-tui")
if termux_startup:
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
tui_dir,
Expand Down Expand Up @@ -7090,7 +7092,11 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
_say(text)

npm_cwd = _workspace_root(web_dir)
npm_workspace_args: tuple[str, ...] = ()
# Scope the install to the web workspace only so that the full workspace
# graph (including apps/desktop with its Electron + node-pty deps) is never
# resolved here. Without --workspace the root package.json's apps/* glob
# would pull in desktop on every web build. See #38772.
npm_workspace_args: tuple[str, ...] = ("--workspace", "web")
if _is_termux_startup_environment():
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
r1 = _run_npm_install_deterministic(
Expand All @@ -7105,7 +7111,7 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
)
_relay(r1)
if fatal:
_say(" Run manually: cd web && npm install && npm run build")
_say(" Run manually: npm install --workspace web && npm run build -w web")
return False
# First attempt — stream output via idle-timeout helper (issue #33788).
# capture_output=True on a long Vite build looks identical to a hang;
Expand Down Expand Up @@ -7147,7 +7153,7 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
)
_relay(r2)
if fatal:
_say(" Run manually: cd web && npm install && npm run build")
_say(" Run manually: npm install --workspace web && npm run build -w web")
return False
_say(" ✓ Web UI built")
return True
Expand Down Expand Up @@ -12373,7 +12379,7 @@ def cmd_dashboard(args):
)
if not (_dist_root / "index.html").exists():
print(f"✗ --skip-build was passed but no web dist found at: {_dist_root}")
print(" Pre-build first: cd web && npm install && npm run build")
print(" Pre-build first: npm install --workspace web && npm run build -w web")
print(" Or drop --skip-build to build automatically.")
sys.exit(1)
print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}")
Expand Down
12 changes: 8 additions & 4 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,14 +846,17 @@ def _run_post_setup(post_setup_key: str):
# batch shims). On POSIX npm_bin is the plain path — same
# behaviour as before.
result = subprocess.run(
[npm_bin, "install", "--silent"],
# --workspaces=false restricts the install to the repo root
# only, avoiding the apps/* glob which would pull in
# apps/desktop (Electron + node-pty) unnecessarily. See #38772.
[npm_bin, "install", "--silent", "--workspaces=false"],
capture_output=True, text=True, cwd=str(PROJECT_ROOT)
)
if result.returncode == 0:
_print_success(" Node.js dependencies installed")
else:
from hermes_constants import display_hermes_home
_print_warning(f" npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install")
_print_warning(f" npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install --workspaces=false")
if result.stderr:
_print_info(f" {result.stderr.strip()[:200]}")
elif not node_modules.exists():
Expand Down Expand Up @@ -951,13 +954,14 @@ def _run_post_setup(post_setup_key: str):
import subprocess
# Absolute npm path so .cmd shim executes on Windows.
result = subprocess.run(
[_npm_bin, "install", "--silent"],
# --workspaces=false avoids resolving apps/desktop. See #38772.
[_npm_bin, "install", "--silent", "--workspaces=false"],
capture_output=True, text=True, cwd=str(PROJECT_ROOT)
)
if result.returncode == 0:
_print_success(" Camofox installed")
else:
_print_warning(" npm install failed - run manually: npm install")
_print_warning(" npm install failed - run manually: npm install --workspaces=false")
if camofox_dir.exists():
_print_info(" Start the Camofox server:")
_print_info(" npx @askjo/camofox-browser")
Expand Down
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@
"web"
],
"scripts": {
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'"
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'",
"install:root": "npm install --workspaces=false",
"install:web": "npm install --workspace web",
"install:tui": "npm install --workspace ui-tui",
"install:desktop": "npm install --workspace apps/desktop",
"audit:root": "npm audit --workspaces=false",
"audit:web": "npm audit --workspace web",
"audit:tui": "npm audit --workspace ui-tui",
"audit:fix:root": "npm audit fix --workspaces=false",
"audit:fix:web": "npm audit fix --workspace web",
"audit:fix:tui": "npm audit fix --workspace ui-tui"
},
"repository": {
"type": "git",
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"al@randomsnowflake.me": "randomsnowflake",
"zakame@zakame.net": "zakame",
"834740219@qq.com": "ViewWay",
"harjoth.khara@gmail.com": "harjothkhara",
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
Expand Down
2 changes: 1 addition & 1 deletion tests/hermes_cli/test_cmd_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ def test_update_refreshes_repo_and_tui_node_dependencies(
# The web/ install runs from the workspace root when the root
# lockfile exists (npm workspaces hoist node_modules upward).
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT),
(["/usr/bin/npm", "ci", "--workspace", "web", "--silent"], PROJECT_ROOT),
]

# The web UI build itself went through the streaming helper.
Expand Down
35 changes: 35 additions & 0 deletions tests/hermes_cli/test_tui_npm_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ def fake_run(*args, **kwargs):
assert calls[0][0][0] == [
"/bin/npm",
"install",
"--workspace",
"ui-tui",
"--silent",
"--no-fund",
"--no-audit",
Expand Down Expand Up @@ -390,3 +392,36 @@ def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None:
"delete them and run `npm install` from the repo root instead: "
+ ", ".join(str(d / "package-lock.json") for d in stray)
)


def test_tui_launch_install_uses_workspace_scope(
tmp_path: Path, main_mod, monkeypatch
) -> None:
"""TUI launch npm install must pass --workspace ui-tui to avoid pulling apps/desktop."""
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
(tui_dir / "dist" / "entry.js").parent.mkdir(parents=True)
(tui_dir / "dist" / "entry.js").write_text("console.log('tui')")
# workspace root: parent has lockfile, tui_dir does not
(tmp_path / "package-lock.json").write_text("{}")

monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/usr/bin/{name}")

npm_calls = []

def fake_run(cmd, **kwargs):
if cmd[0].endswith("npm"):
npm_calls.append(cmd)
return types.SimpleNamespace(returncode=0, stdout="", stderr="")

monkeypatch.setattr(main_mod.subprocess, "run", fake_run)

main_mod._make_tui_argv(tui_dir, tui_dev=False)

assert npm_calls, "expected npm install to be called"
install_cmd = npm_calls[0]
assert "--workspace" in install_cmd
assert "ui-tui" in install_cmd
15 changes: 14 additions & 1 deletion tests/hermes_cli/test_web_ui_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@ def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"

def test_npm_install_uses_workspace_web_scope(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run, \
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_ok):
result = _build_web_ui(web_dir)
assert result is True
install_cmd = mock_run.call_args[0][0]
assert "--workspace" in install_cmd
assert install_cmd[install_cmd.index("--workspace") + 1] == "web"

def test_web_build_uses_idle_timeout_helper(self, tmp_path):
"""npm run build now goes through _run_with_idle_timeout (issue #33788).

Expand Down Expand Up @@ -205,7 +218,7 @@ def test_desktop_web_install_uses_existing_workspace_root(

assert result is True
args, kwargs = mock_run.call_args
assert args[0] == ["/usr/bin/npm", "ci", "--silent"]
assert args[0] == ["/usr/bin/npm", "ci", "--workspace", "web", "--silent"]
assert kwargs["cwd"] == tmp_path


Expand Down
Loading