diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index f7c9cfff8d..14d96ed501 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1176,6 +1176,14 @@ def _hermes_home_for_target_user(target_home_dir: str) -> str: return str(current_hermes) +def _systemd_quote_arg(value: str) -> str: + """Quote a systemd command/path argument only when needed.""" + text = str(value) + if text and not any(ch.isspace() for ch in text) and '"' not in text and "\\" not in text: + return text + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + + def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str: python_path = get_python_path() working_dir = str(PROJECT_ROOT) @@ -1210,6 +1218,11 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries)) path_entries.extend(common_bin_paths) sane_path = ":".join(path_entries) + profile_suffix = f" {profile_arg}" if profile_arg else "" + exec_start = ( + f"{_systemd_quote_arg(python_path)} -m hermes_cli.main" + f"{profile_suffix} gateway run --replace" + ) return f"""[Unit] Description={SERVICE_DESCRIPTION} After=network-online.target @@ -1221,8 +1234,8 @@ StartLimitBurst=5 Type=simple User={username} Group={group_name} -ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace -WorkingDirectory={working_dir} +ExecStart={exec_start} +WorkingDirectory={_systemd_quote_arg(working_dir)} Environment="HOME={home_dir}" Environment="USER={username}" Environment="LOGNAME={username}" @@ -1248,6 +1261,11 @@ WantedBy=multi-user.target path_entries.extend(_build_user_local_paths(Path.home(), path_entries)) path_entries.extend(common_bin_paths) sane_path = ":".join(path_entries) + profile_suffix = f" {profile_arg}" if profile_arg else "" + exec_start = ( + f"{_systemd_quote_arg(python_path)} -m hermes_cli.main" + f"{profile_suffix} gateway run --replace" + ) return f"""[Unit] Description={SERVICE_DESCRIPTION} After=network.target @@ -1256,8 +1274,8 @@ StartLimitBurst=5 [Service] Type=simple -ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace -WorkingDirectory={working_dir} +ExecStart={exec_start} +WorkingDirectory={_systemd_quote_arg(working_dir)} Environment="PATH={sane_path}" Environment="VIRTUAL_ENV={venv_dir}" Environment="HERMES_HOME={hermes_home}" diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 3c03aab7e8..46aba27d86 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -100,6 +100,23 @@ class TestGeneratedSystemdUnits: assert "/home/test/.nvm/versions/node/v24.14.0/bin" in unit + def test_user_unit_quotes_execstart_and_workdir_paths_with_spaces(self, tmp_path, monkeypatch): + project_root = tmp_path / "Hermes Agent" + venv_dir = project_root / "venv" + python_path = venv_dir / "bin" / "python" + python_path.parent.mkdir(parents=True) + python_path.write_text("", encoding="utf-8") + + monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", project_root) + monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: venv_dir) + monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(python_path)) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + + unit = gateway_cli.generate_systemd_unit(system=False) + + assert f'ExecStart="{python_path}" -m hermes_cli.main gateway run --replace' in unit + assert f'WorkingDirectory="{project_root}"' in unit + def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self): unit = gateway_cli.generate_systemd_unit(system=True)