From b1a02bdb725bfa9313dc967a6feb06aa3b5ebd09 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 1 Jul 2026 23:24:38 +0000 Subject: [PATCH] #5309: ctl.sh load ~/.hermes/.env --- ctl.sh | 119 +++++++++++++++++++++++++++++++++------ tests/test_ctl_script.py | 76 +++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 16 deletions(-) diff --git a/ctl.sh b/ctl.sh index de62c3de6..2e827aad9 100755 --- a/ctl.sh +++ b/ctl.sh @@ -29,6 +29,60 @@ ensure_home() { mkdir -p "${HERMES_HOME}" "${DEFAULT_STATE_DIR}" } +_apply_env_file_safely() { + local env_file="$1" + local line key value + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#${line%%[![:space:]]*}}" + [[ -z "${line}" || "${line}" == \#* ]] && continue + if [[ "${line}" =~ ^export[[:space:]]+(.+)$ ]]; then + line="${BASH_REMATCH[1]}" + line="${line#${line%%[![:space:]]*}}" + fi + [[ "${line}" == *=* ]] || continue + + key="${line%%=*}" + value="${line#*=}" + key="${key//[[:space:]]/}" + [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + case "${key}" in + UID | GID | EUID | EGID | PPID) continue ;; + esac + + value="${value#${value%%[![:space:]]*}}" + if [[ "${value}" =~ ^\"(([^\"\\]|\\.)*)\"([[:space:]]*\#.*)?[[:space:]]*$ ]]; then + value="${BASH_REMATCH[1]}" + value="$(printf '%s' "$value" | awk '{ + i = 1 + len = length($0) + while (i <= len) { + c = substr($0, i, 1) + if (c == "\\" && i < len) { + nc = substr($0, i+1, 1) + if (nc == "n") printf "\n" + else if (nc == "r") printf "\r" + else if (nc == "t") printf "\t" + else if (nc == "\"") printf "\"" + else if (nc == "\\") printf "\\" + else { printf "\\%s", nc } + i += 2 + } else { + printf "%s", c + i++ + } + } + }')" + elif [[ "${value}" =~ ^\'([^\']*)\'([[:space:]]*\#.*)?[[:space:]]*$ ]]; then + value="${BASH_REMATCH[1]}" + else + value="${value%%[[:space:]]\#*}" + value="${value%${value##*[![:space:]]}}" + fi + + export "${key}=${value}" + done < "${env_file}" +} + _load_repo_dotenv_preserving_env() { [[ "${HERMES_WEBUI_NO_DOTENV:-0}" == "1" ]] && return 0 local env_file="${REPO_ROOT}/.env" @@ -40,7 +94,9 @@ _load_repo_dotenv_preserving_env() { line="${line#${line%%[![:space:]]*}}" [[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue key="${line%%=*}" - key="${key#export }" + if [[ "${key}" =~ ^export[[:space:]]+(.+)$ ]]; then + key="${BASH_REMATCH[1]}" + fi key="${key//[[:space:]]/}" [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue # Skip shell-readonly names (UID/GID/EUID/EGID/PPID); re-exporting them @@ -54,21 +110,50 @@ _load_repo_dotenv_preserving_env() { fi done < "${env_file}" - set -a - # Filter out shell-readonly vars (UID, GID, EUID, EGID, PPID) before - # sourcing. docker-compose.yml's macOS instructions document - # `echo "UID=$(id -u)" >> .env`; under `set -euo pipefail` a raw - # `source .env` then aborts with "UID: readonly variable" before - # `ctl.sh status` can print anything. Mirrors the guard start.sh uses. - local _ctl_env_filtered - _ctl_env_filtered="$(mktemp "${TMPDIR:-/tmp}/hermes-webui-ctl-env.XXXXXX")" - # shellcheck disable=SC2064 - trap "rm -f '${_ctl_env_filtered}'" RETURN - grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${env_file}" > "${_ctl_env_filtered}" || true - # shellcheck source=/dev/null - source "${_ctl_env_filtered}" - rm -f "${_ctl_env_filtered}" - set +a + _apply_env_file_safely "${env_file}" + + local assignment + if [[ ${#preserved[@]} -gt 0 ]]; then + for assignment in "${preserved[@]}"; do + export "${assignment}" + done + fi +} + +_load_hermes_dotenv() { + # Also load ~/.hermes/.env so that ${VAR} references in config.yaml can + # resolve against provider credentials defined in the Hermes env file. + # Repo .env takes precedence (loaded above); variables already exported + # into the shell environment (including those just set by repo .env) are + # captured in preserved[] before _apply_env_file_safely runs and are + # restored afterwards, so this acts as a fallback source for vars the + # repo .env did not define. + [[ "${HERMES_WEBUI_NO_DOTENV:-0}" == "1" ]] && return 0 + local hermes_home="${HERMES_HOME:-${HOME}/.hermes}" + local hermes_env="${hermes_home}/.env" + [[ -f "${hermes_env}" ]] || return 0 + + local -a preserved=() + local line key value + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#${line%%[![:space:]]*}}" + [[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue + key="${line%%=*}" + if [[ "${key}" =~ ^export[[:space:]]+(.+)$ ]]; then + key="${BASH_REMATCH[1]}" + fi + key="${key//[[:space:]]/}" + [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + case "${key}" in + UID | GID | EUID | EGID | PPID) continue ;; + esac + if [[ -n "${!key+x}" ]]; then + value="${!key}" + preserved+=("${key}=${value}") + fi + done < "${hermes_env}" + + _apply_env_file_safely "${hermes_env}" local assignment if [[ ${#preserved[@]} -gt 0 ]]; then @@ -342,6 +427,7 @@ _launchd_webui_pid() { start_cmd() { ensure_home _load_repo_dotenv_preserving_env + _load_hermes_dotenv export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${DEFAULT_STATE_DIR}}" mkdir -p "${HERMES_WEBUI_STATE_DIR}" _parse_launch_binding "$@" @@ -447,6 +533,7 @@ except Exception: status_cmd() { ensure_home _load_repo_dotenv_preserving_env + _load_hermes_dotenv _load_state_if_present local host="${HOST:-${HERMES_WEBUI_HOST:-127.0.0.1}}" local port="${PORT:-${HERMES_WEBUI_PORT:-8787}}" diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index c5e62a200..27ba3267c 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -325,6 +325,82 @@ def test_start_loads_dotenv_but_inline_overrides_win(tmp_path): assert_process_exits(pid) +def test_start_loads_dotenv_double_quoted_port_with_trailing_comment(tmp_path): + repo_root = tmp_path / "repo" + repo_root.mkdir() + _seed_ctl_repo(repo_root) + (repo_root / "bootstrap.py").write_text("# fake bootstrap target\n", encoding="utf-8") + + fake_python = tmp_path / "fake-python" + fake_log = tmp_path / "fake-python.log" + write_fake_python(fake_python) + (repo_root / ".env").write_text( + 'HERMES_WEBUI_PORT="19004" # inline comment\n', + encoding="utf-8", + ) + + result = run_ctl( + tmp_path, + "start", + env={ + "HERMES_WEBUI_PYTHON": str(fake_python), + "FAKE_PYTHON_LOG": str(fake_log), + "HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1", + }, + repo_root=repo_root, + load_dotenv=True, + ) + + assert result.returncode == 0, result.stderr + result.stdout + pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid") + try: + fake_output = wait_for_file_text(fake_log, contains="host=127.0.0.1 port=19004") + assert "host=127.0.0.1 port=19004" in fake_output + finally: + stop = run_ctl(tmp_path, "stop", repo_root=repo_root) + assert stop.returncode == 0, stop.stderr + stop.stdout + _kill_tree(pid) + assert_process_exits(pid) + + +def test_start_loads_dotenv_export_tab_host_assignment(tmp_path): + repo_root = tmp_path / "repo" + repo_root.mkdir() + _seed_ctl_repo(repo_root) + (repo_root / "bootstrap.py").write_text("# fake bootstrap target\n", encoding="utf-8") + + fake_python = tmp_path / "fake-python" + fake_log = tmp_path / "fake-python.log" + write_fake_python(fake_python) + (repo_root / ".env").write_text( + "export\tHERMES_WEBUI_HOST=0.0.0.0\nHERMES_WEBUI_PORT=19005\n", + encoding="utf-8", + ) + + result = run_ctl( + tmp_path, + "start", + env={ + "HERMES_WEBUI_PYTHON": str(fake_python), + "FAKE_PYTHON_LOG": str(fake_log), + "HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1", + }, + repo_root=repo_root, + load_dotenv=True, + ) + + assert result.returncode == 0, result.stderr + result.stdout + pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid") + try: + fake_output = wait_for_file_text(fake_log, contains="host=0.0.0.0 port=19005") + assert "host=0.0.0.0 port=19005" in fake_output + finally: + stop = run_ctl(tmp_path, "stop", repo_root=repo_root) + assert stop.returncode == 0, stop.stderr + stop.stdout + _kill_tree(pid) + assert_process_exits(pid) + + def test_stale_pid_file_is_removed_without_killing_unrelated_process(tmp_path): hermes_home = tmp_path / ".hermes" hermes_home.mkdir()