From af560f940eeb9cc6fca21934482c61517d47e6f2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Wed, 3 Jun 2026 16:44:58 +0000 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.234=20=E2=80=94=20Release=20HB?= =?UTF-8?q?=20(stage-q4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate-instance startup guard + remote-terminal workspace paths. - #3289 refuse startup when a live instance already serves the port (live-listener probe + Windows SO_EXCLUSIVEADDRUSE), preserving fast restart — reworked from the original global-SO_REUSEADDR-disable that broke restart (@rodboev, unheld this round) - #3486 allow remote/SSH terminal profiles to use target-side workspace paths under terminal.cwd without a server-local stat (local profiles unchanged) (@dso2ng) Co-authored-by: rodboev Co-authored-by: dso2ng --- CHANGELOG.md | 6 ++ api/workspace.py | 97 ++++++++++++++++++++++--- server.py | 28 +++++++ tests/test_remote_terminal_workspace.py | 57 +++++++++++++++ tests/test_server_port_exclusivity.py | 90 +++++++++++++++++++++++ 5 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 tests/test_remote_terminal_workspace.py create mode 100644 tests/test_server_port_exclusivity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 709ea7fa20b..fc3353948de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.234] — 2026-06-03 — Release HB (stage-q4 — duplicate-instance startup guard + remote-terminal workspace paths) + +### Fixed +- The server now refuses to start when a live instance is already responding on the configured port, instead of silently sharing it (a Windows/macOS hazard where `SO_REUSEADDR` semantics let two processes bind 8787 at once, #3289). Rather than globally disabling `SO_REUSEADDR` (which would brick legitimate fast restarts — `ctl.sh restart` and the `os.execv` self-update path rebind immediately and would hit the TIME_WAIT window), startup now runs a live-listener probe (`_abort_if_already_serving`): a TCP connect + `GET /health` with a 2s timeout. A live instance answers and startup aborts with a clear message; a dying instance whose socket still lingers in the kernel backlog accepts the connection but never responds, so the probe times out and startup proceeds — preserving fast restart. On Windows, `SO_EXCLUSIVEADDRUSE` is set in a `server_bind()` override to get true exclusive binding (POSIX keeps the inherited `allow_reuse_address = True`) (#3289, @rodboev). +- Remote/SSH terminal profiles can now use target-side workspace paths that don't exist on the WebUI host. Workspace validation/resolution previously `stat()`-ed every path against the WebUI server's local filesystem, so a `terminal.cwd` (or session workspace) living on the remote target was rejected as nonexistent. For profiles whose terminal backend is non-local, paths **under the configured `terminal.cwd`** now pass validation without a server-local existence check, and stale server-local `last_workspace` values are ignored unless they fall under the remote cwd. Local profiles are unchanged — the bypass only fires for remote backends and only for paths contained within `terminal.cwd` (#3486, @dso2ng). + ## [v0.51.233] — 2026-06-03 — Release HA (stage-q3 — session-truncate keep_count guard against silent transcript loss) ### Fixed diff --git a/api/workspace.py b/api/workspace.py index 286bd9a17aa..4a879216851 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -57,6 +57,46 @@ def _last_workspace_file() -> Path: return _profile_state_dir() / 'last_workspace.txt' +def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool: + """Return True when the active terminal backend runs outside this WebUI host.""" + if not isinstance(terminal_cfg, dict): + return False + backend = str(terminal_cfg.get('backend') or '').strip().lower() + return backend not in ('', 'local') + + +def _remote_terminal_cwd() -> str | None: + """Return target-side terminal cwd for remote profiles, without local stat().""" + try: + from api.config import get_config + + terminal_cfg = get_config().get('terminal', {}) + if not _is_remote_terminal_backend(terminal_cfg): + return None + cwd = str(terminal_cfg.get('cwd') or '').strip() + if not cwd or cwd == '.': + return None + return cwd + except Exception: + logger.debug("Failed to read remote terminal cwd", exc_info=True) + return None + + +def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None: + """Return a non-stat'ed target-side Path when it is under terminal.cwd.""" + cwd = _remote_terminal_cwd() + if not cwd: + return None + raw = _strip_surrounding_quotes(str(path)).strip() + if not raw: + return None + candidate = Path(raw).expanduser().resolve() + base = Path(cwd).expanduser().resolve() + if candidate == base or _is_within(candidate, base): + return candidate + return None + + def _profile_default_workspace() -> str: """Read the profile's default workspace from its config.yaml. @@ -65,25 +105,31 @@ def _profile_default_workspace() -> str: 2. 'default_workspace' — alternate explicit key 3. 'terminal.cwd' — hermes-agent terminal working dir (most common) + For remote/SSH terminal profiles, ``terminal.cwd`` lives on the target + machine, not on the WebUI server. In that case return it without a + server-local existence check so WebUI can send the correct workspace hint + to the agent/tool backend. + Falls back to the live DEFAULT_WORKSPACE from api.config. """ try: from api.config import get_config cfg = get_config() + terminal_cfg = cfg.get('terminal', {}) + remote_terminal = _is_remote_terminal_backend(terminal_cfg) # Explicit webui workspace keys first for key in ('workspace', 'default_workspace'): ws = cfg.get(key) if ws: p = Path(str(ws)).expanduser().resolve() - if p.is_dir(): + if remote_terminal or p.is_dir(): return str(p) # Fall through to terminal.cwd — the agent's configured working directory - terminal_cfg = cfg.get('terminal', {}) if isinstance(terminal_cfg, dict): cwd = terminal_cfg.get('cwd', '') if cwd and str(cwd) not in ('.', ''): p = Path(str(cwd)).expanduser().resolve() - if p.is_dir(): + if remote_terminal or p.is_dir(): return str(p) except (ImportError, Exception): logger.debug("Failed to load profile default workspace config") @@ -225,19 +271,35 @@ def save_workspaces(workspaces: list) -> None: def get_last_workspace() -> str: + remote_cwd = _remote_terminal_cwd() + + def valid_last_workspace(raw: str) -> str | None: + if not raw: + return None + if remote_cwd: + # For remote/SSH profiles, last_workspace is target-side state. Do + # not accept stale server-local paths merely because they exist on + # the WebUI host; require the value to stay under terminal.cwd. + if _remote_terminal_workspace_candidate(raw) is not None: + return raw + return None + if Path(raw).is_dir(): + return raw + return None + lw_file = _last_workspace_file() if lw_file.exists(): try: - p = lw_file.read_text(encoding='utf-8').strip() - if p and Path(p).is_dir(): + p = valid_last_workspace(lw_file.read_text(encoding='utf-8').strip()) + if p: return p except Exception: logger.debug("Failed to read last workspace from %s", lw_file) # Fallback: try global file if _GLOBAL_LW_FILE.exists(): try: - p = _GLOBAL_LW_FILE.read_text(encoding='utf-8').strip() - if p and Path(p).is_dir(): + p = valid_last_workspace(_GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()) + if p: return p except Exception: logger.debug("Failed to read global last workspace") @@ -574,8 +636,17 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: candidate = Path(path).expanduser().resolve() access_error = _workspace_access_error(candidate) + remote_candidate = _remote_terminal_workspace_candidate(path) if access_error: - raise ValueError(access_error) + # For remote terminal profiles, workspace paths belong to the target + # machine. Allow paths under terminal.cwd so session switching can + # update the workspace hint even though this WebUI host cannot stat + # the target-side path. + if remote_candidate is None: + raise ValueError(access_error) + + if remote_candidate is not None: + return remote_candidate # (A) Trusted if under the user's home directory — cross-platform via Path.home() # Must be checked before system roots to allow symlinks like /var/home. @@ -658,8 +729,16 @@ def validate_workspace_to_add(path: str) -> Path: candidate = Path(path).expanduser().resolve() access_error = _workspace_access_error(candidate) + remote_candidate = _remote_terminal_workspace_candidate(path) if access_error: - raise ValueError(access_error) + # Remote terminal profiles validate workspace existence on the target + # machine, not on the WebUI server. Permit target-side paths under + # terminal.cwd. + if remote_candidate is None: + raise ValueError(access_error) + + if remote_candidate is not None: + return remote_candidate # Home directory is always trusted regardless of where it lives on disk # (e.g. /var/home/... on systemd-homed Fedora/RHEL). diff --git a/server.py b/server.py index 39ba81c3a15..95a28977c16 100644 --- a/server.py +++ b/server.py @@ -184,6 +184,13 @@ def __init__(self, *args, **kwargs): self.accept_loop_requests_total = 0 self.accept_loop_last_request_at = 0.0 + def server_bind(self): + if sys.platform == 'win32': + self.allow_reuse_address = False + SO_EXCLUSIVEADDRUSE = getattr(socket, 'SO_EXCLUSIVEADDRUSE', -5) + self.socket.setsockopt(socket.SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1) + super().server_bind() + def _handle_request_noblock(self): """Record accept-loop progress before dispatching a request handler. @@ -477,6 +484,25 @@ def _log_shutdown_audit(reason: str = "serve_forever_exit") -> None: ) +def _abort_if_already_serving(host: str, port: int) -> None: + """Refuse to start if a live HTTP server is already responding on this port.""" + probe_host = '127.0.0.1' if host in ('0.0.0.0', '', '::') else host + try: + with socket.create_connection((probe_host, port), timeout=2) as s: + s.sendall(b'GET /health HTTP/1.0\r\nHost: localhost\r\n\r\n') + s.settimeout(2) + data = s.recv(512) + if data: + print( + f'[!!] FATAL: Another server is already responding on' + f' {probe_host}:{port}. Stop the existing instance first.', + flush=True, + ) + sys.exit(1) + except (ConnectionRefusedError, ConnectionResetError, OSError, socket.timeout): + pass + + def main() -> None: from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND @@ -572,6 +598,7 @@ def main() -> None: except Exception as e: print(f'[!!] WARNING: Plugin loading failed: {e}', flush=True) + _abort_if_already_serving(HOST, PORT) httpd = QuietHTTPServer((HOST, PORT), Handler) # ── TLS/HTTPS setup (optional) ───────────────────────────────────────── @@ -597,6 +624,7 @@ def main() -> None: try: httpd.serve_forever() finally: + httpd.server_close() _log_shutdown_audit() # Stop the gateway watcher on shutdown try: diff --git a/tests/test_remote_terminal_workspace.py b/tests/test_remote_terminal_workspace.py new file mode 100644 index 00000000000..04155f87049 --- /dev/null +++ b/tests/test_remote_terminal_workspace.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from api import config as api_config +from api import workspace + + +REMOTE_CWD = "/Users/joeyshiue" + + +def _remote_config(**overrides): + cfg = {"terminal": {"backend": "ssh", "cwd": REMOTE_CWD}} + cfg.update(overrides) + return cfg + + +def test_remote_terminal_cwd_is_profile_default_without_local_stat(monkeypatch, tmp_path): + fallback = tmp_path / "fallback" + fallback.mkdir() + + monkeypatch.setattr(api_config, "DEFAULT_WORKSPACE", fallback) + monkeypatch.setattr(api_config, "get_config", lambda: _remote_config()) + + assert workspace._profile_default_workspace() == REMOTE_CWD + + +def test_remote_terminal_last_workspace_ignores_stale_local_path(monkeypatch, tmp_path): + stale_local = tmp_path / "stale-local" + stale_local.mkdir() + last_workspace = tmp_path / "last_workspace.txt" + last_workspace.write_text(str(stale_local), encoding="utf-8") + + monkeypatch.setattr(api_config, "get_config", lambda: _remote_config()) + monkeypatch.setattr(workspace, "_last_workspace_file", lambda: last_workspace) + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", tmp_path / "missing-global-last-workspace.txt") + + assert workspace.get_last_workspace() == REMOTE_CWD + + +def test_remote_terminal_workspace_paths_under_cwd_do_not_require_local_existence(monkeypatch): + monkeypatch.setattr(api_config, "get_config", lambda: _remote_config()) + + target_side_project = f"{REMOTE_CWD}/projects/demo" + + assert workspace.validate_workspace_to_add(target_side_project) == Path(target_side_project).resolve() + assert workspace.resolve_trusted_workspace(target_side_project) == Path(target_side_project).resolve() + + +def test_remote_terminal_workspace_paths_outside_cwd_still_reject(monkeypatch): + monkeypatch.setattr(api_config, "get_config", lambda: _remote_config()) + + with pytest.raises(ValueError, match="Path does not exist"): + workspace.validate_workspace_to_add("/Users/other/projects/demo") + + with pytest.raises(ValueError, match="Path does not exist"): + workspace.resolve_trusted_workspace("/Users/other/projects/demo") diff --git a/tests/test_server_port_exclusivity.py b/tests/test_server_port_exclusivity.py new file mode 100644 index 00000000000..89eaf4176db --- /dev/null +++ b/tests/test_server_port_exclusivity.py @@ -0,0 +1,90 @@ +"""Duplicate-instance guard: a second server on the same port must be detected +and refused before bind, not silently shared (#3289).""" + +from __future__ import annotations + +import socket +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from tests._pytest_port import TEST_PORT + + +# ── SO_EXCLUSIVEADDRUSE on Windows ────────────────────────────────────────── + +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows-only socket option') +def test_exclusive_addr_use_set_on_windows(): + from server import QuietHTTPServer + port = TEST_PORT + 901 + httpd = QuietHTTPServer(('127.0.0.1', port), BaseHTTPRequestHandler) + try: + val = httpd.socket.getsockopt( + socket.SOL_SOCKET, + getattr(socket, 'SO_EXCLUSIVEADDRUSE', -5), + ) + assert val != 0, 'SO_EXCLUSIVEADDRUSE should be set on Windows' + finally: + httpd.server_close() + + +# ── Live-listener probe ───────────────────────────────────────────────────── + +def test_probe_detects_live_server(): + """_abort_if_already_serving must call sys.exit when a live server responds.""" + from server import _abort_if_already_serving + + port = TEST_PORT + 902 + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(b'ok') + def log_message(self, *a): + pass + + httpd = HTTPServer(('127.0.0.1', port), Handler) + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + try: + with pytest.raises(SystemExit): + _abort_if_already_serving('127.0.0.1', port) + finally: + httpd.shutdown() + httpd.server_close() + + +def test_probe_allows_startup_when_nothing_listening(): + """_abort_if_already_serving must return normally on a free port.""" + from server import _abort_if_already_serving + + port = TEST_PORT + 903 + _abort_if_already_serving('127.0.0.1', port) + + +def test_probe_allows_startup_on_unresponsive_socket(): + """A socket that accepts but never responds (e.g. dying instance still in + kernel backlog) should not block startup.""" + from server import _abort_if_already_serving + + port = TEST_PORT + 904 + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(('127.0.0.1', port)) + srv.listen(1) + try: + _abort_if_already_serving('127.0.0.1', port) + finally: + srv.close() + + +def test_probe_normalizes_wildcard_host(): + """0.0.0.0 and :: should probe 127.0.0.1, not the literal wildcard.""" + from server import _abort_if_already_serving + + port = TEST_PORT + 905 + _abort_if_already_serving('0.0.0.0', port) + _abort_if_already_serving('::', port)