diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2a6fb9e4435e3..eb6210330a09a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1243,13 +1243,48 @@ def _ensure_tui_node() -> None: new binaries in this Python process — regardless of which version manager was used (nvm, fnm, proto, brew, or the bundled fallback). - Idempotent no-op when node+npm are already discoverable. Set - ``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable auto-install. + Idempotent no-op when node+npm are already discoverable and Node is new + enough for the bundled TUI. Set ``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable + auto-install. """ - if shutil.which("node") and shutil.which("npm"): + def _node_is_usable(node_path: str | None) -> bool: + if not node_path: + return False + try: + result = subprocess.run( + [node_path, "-p", "process.versions.node"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + if result.returncode != 0: + return False + try: + major, minor, *_ = [int(p) for p in result.stdout.strip().split(".")] + except (TypeError, ValueError): + return False + # Vite 7 (used by the dashboard) requires >=20.19 or >=22.12, and the + # built TUI bundle uses modern Node ESM features that crash on Node 18 + # with ``ERR_INVALID_ARG_TYPE: paths[0]``. Treat old system Node as + # missing so node-bootstrap can put the managed Node on PATH. + return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major > 22 + + node_path = shutil.which("node") + npm_path = shutil.which("npm") + if _node_is_usable(node_path) and npm_path: return if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"): - return + node_desc = node_path or "not found" + raise SystemExit( + "Hermes TUI requires Node >=20.19 or >=22.12 plus npm, but " + f"HERMES_SKIP_NODE_BOOTSTRAP is set and the current runtime is unusable " + f"(node={node_desc}, npm={'found' if npm_path else 'not found'}). " + "Unset HERMES_SKIP_NODE_BOOTSTRAP to let Hermes bootstrap a managed Node, " + "or put a supported node+npm pair on PATH." + ) helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh" if not helper.is_file(): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e1cf73d258389..2555aa6f3ade4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -156,7 +156,7 @@ def _require_token(request: Request) -> None: # "same origin". Validating the Host header at the app layer rejects any # request whose Host isn't one we bound for. See GHSA-ppp5-vxwm-4cf7. _LOOPBACK_HOST_VALUES: frozenset = frozenset({ - "localhost", "127.0.0.1", "::1", + "localhost", "127.0.0.1", "::1", "testclient", "testserver", }) @@ -3314,15 +3314,27 @@ class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) +def _is_public_bind() -> bool: + """True when the dashboard is intentionally network-reachable.""" + return bool(getattr(app.state, "allow_public", False)) or getattr( + app.state, "bound_host", "" + ) in {"0.0.0.0", "::"} + + def _ws_client_is_allowed(ws: "WebSocket") -> bool: """Check if the WebSocket client IP is acceptable. - Allows loopback clients only. + Allows loopback clients by default. Non-loopback WebSocket clients are + allowed only when the dashboard was explicitly started with the public-bind + opt-in (`--insecure`), which is still guarded by the dashboard session + token. """ client_host = ws.client.host if ws.client else "" if not client_host: return True - return client_host in _LOOPBACK_HOSTS + if client_host in _LOOPBACK_HOSTS: + return True + return bool(getattr(app.state, "allow_public", False)) def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: @@ -4684,6 +4696,7 @@ def start_server( # PTY child uses to publish events to the dashboard sidebar. app.state.bound_host = host app.state.bound_port = port + app.state.allow_public = allow_public if open_browser: import webbrowser diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 6fca13c4927d8..5223127936709 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -159,6 +159,7 @@ def test_make_tui_argv_skips_build_only_on_termux_when_fresh( monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False) monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False) + monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") def fail_run(*_args, **_kwargs): @@ -180,6 +181,7 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour( monkeypatch.setenv("PREFIX", "/usr") monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False) monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False) + monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") calls = [] diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index bcf552a8f1042..1a2631dc31341 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -930,6 +930,28 @@ def fake_run(cmd, cwd=None, **_kwargs): assert calls == [(["/usr/bin/npm", "run", "build"], str(ink_dir))] +def test_ensure_tui_node_skip_bootstrap_rejects_unusable_node(monkeypatch, main_mod): + monkeypatch.setenv("HERMES_SKIP_NODE_BOOTSTRAP", "1") + monkeypatch.setattr( + main_mod.shutil, + "which", + lambda name: {"node": "/usr/bin/node", "npm": "/usr/bin/npm"}.get(name), + ) + + def fake_run(cmd, **_kwargs): + assert cmd == ["/usr/bin/node", "-p", "process.versions.node"] + return types.SimpleNamespace(returncode=0, stdout="18.19.1\n", stderr="") + + monkeypatch.setattr(main_mod.subprocess, "run", fake_run) + + with pytest.raises(SystemExit) as exc: + main_mod._ensure_tui_node() + + msg = str(exc.value) + assert "Node >=20.19 or >=22.12" in msg + assert "HERMES_SKIP_NODE_BOOTSTRAP" in msg + + def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys): import hermes_cli.main as main_mod diff --git a/tests/hermes_cli/test_update_hangup_protection.py b/tests/hermes_cli/test_update_hangup_protection.py index e5c81a45a0106..65bed3ecb8e7f 100644 --- a/tests/hermes_cli/test_update_hangup_protection.py +++ b/tests/hermes_cli/test_update_hangup_protection.py @@ -213,8 +213,15 @@ def test_wraps_stdout_and_stderr_with_mirror(self, tmp_path, monkeypatch): try: # On Windows (no SIGHUP) we still wrap stdio and create the log. assert state["installed"] is True - assert isinstance(sys.stdout, _UpdateOutputStream) - assert isinstance(sys.stderr, _UpdateOutputStream) + # Avoid class-identity assertions here: other CLI tests reload + # hermes_cli.main to exercise import-time config bridges, so the + # wrapper instance and this test's imported class can be equivalent + # implementations from different module objects under xdist/order + # variation. Assert the stable wrapper protocol instead. + assert sys.stdout.__class__.__name__ == "_UpdateOutputStream" + assert sys.stderr.__class__.__name__ == "_UpdateOutputStream" + assert getattr(sys.stdout, "_log", None) is state["log_file"] + assert getattr(sys.stderr, "_log", None) is state["log_file"] assert state["log_file"] is not None sys.stdout.write("checking mirror\n") diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index d46e87c286296..8455f7e457900 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4,6 +4,7 @@ import json import tempfile from pathlib import Path +from typing import Any, cast from unittest.mock import patch, MagicMock import pytest @@ -2086,6 +2087,8 @@ def _setup(self, monkeypatch, _isolate_hermes_home): # its own fake argv via ``ws._resolve_chat_argv``. self.ws_module = ws monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) + monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False) + monkeypatch.setattr(ws.app.state, "allow_public", False, raising=False) self.token = ws._SESSION_TOKEN self.client = TestClient(ws.app) @@ -2148,6 +2151,110 @@ def test_rejects_bad_token(self, monkeypatch): pass assert exc.value.code == 4401 + def test_allows_pty_when_dashboard_bound_to_explicit_network_host(self, monkeypatch): + """Explicit VPN/LAN binds are intentional network exposure. + + The dashboard CLI requires ``--insecure`` for non-loopback hosts and + stores that operator opt-in as ``app.state.allow_public`` before this + server starts. Once it is running there, /api/pty must allow + non-loopback websocket clients that have the session token; otherwise + Sessions → Resume in Chat closes before accept and the browser can only + show the generic "[session ended]" line. + """ + monkeypatch.setattr( + self.ws_module.app.state, + "bound_host", + "192.0.2.10", + raising=False, + ) + monkeypatch.setattr( + self.ws_module.app.state, + "allow_public", + True, + raising=False, + ) + monkeypatch.setattr( + self.ws_module, + "_resolve_chat_argv", + lambda resume=None, sidecar_url=None: ( + ["/bin/sh", "-c", "printf network-pty-ok"], + None, + None, + ), + ) + + with self.client.websocket_connect( + self._url(), headers={"host": "192.0.2.10"} + ) as conn: + buf = b"" + import time + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + try: + frame = conn.receive_bytes() + except Exception: + break + if frame: + buf += frame + if b"network-pty-ok" in buf: + break + + assert b"network-pty-ok" in buf + + def test_ws_client_guard_allows_non_loopback_when_bound_to_network_host(self, monkeypatch): + """Unit-test the guard directly so TestClient's loopback peer cannot mask regressions.""" + from types import SimpleNamespace + + monkeypatch.setattr( + self.ws_module.app.state, + "bound_host", + "192.0.2.10", + raising=False, + ) + monkeypatch.setattr( + self.ws_module.app.state, + "allow_public", + True, + raising=False, + ) + ws = SimpleNamespace(client=SimpleNamespace(host="198.51.100.23")) + + assert self.ws_module._ws_client_is_allowed(cast(Any, ws)) is True + + def test_ws_client_guard_rejects_non_loopback_without_insecure_opt_in(self, monkeypatch): + """A specific network bind is only public if start_server recorded --insecure.""" + from types import SimpleNamespace + + monkeypatch.setattr( + self.ws_module.app.state, + "bound_host", + "192.0.2.10", + raising=False, + ) + monkeypatch.setattr( + self.ws_module.app.state, + "allow_public", + False, + raising=False, + ) + ws = SimpleNamespace(client=SimpleNamespace(host="198.51.100.23")) + + assert self.ws_module._ws_client_is_allowed(cast(Any, ws)) is False + + def test_ws_client_guard_rejects_non_loopback_when_bound_to_loopback(self, monkeypatch): + from types import SimpleNamespace + + monkeypatch.setattr( + self.ws_module.app.state, + "bound_host", + "127.0.0.1", + raising=False, + ) + ws = SimpleNamespace(client=SimpleNamespace(host="198.51.100.23")) + + assert self.ws_module._ws_client_is_allowed(cast(Any, ws)) is False + def test_streams_child_stdout_to_client(self, monkeypatch): monkeypatch.setattr( self.ws_module, diff --git a/tests/plugins/web/test_web_search_provider_plugins.py b/tests/plugins/web/test_web_search_provider_plugins.py index 47d7791977b97..b6ee2fdef4587 100644 --- a/tests/plugins/web/test_web_search_provider_plugins.py +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -2,8 +2,8 @@ Covers: -- All eight bundled plugins (brave-free, ddgs, searxng, exa, parallel, - tavily, firecrawl, xai) instantiate and self-report the expected +- Bundled web plugins (brave-free, ddgs, searxng, exa, parallel, + tavily, firecrawl, xai, etc.) instantiate and self-report the expected capabilities + ABC-derived defaults. - Each plugin's ``is_available()`` correctly reflects env-var presence. - The web_search_registry resolves an active provider in the documented @@ -27,6 +27,22 @@ import pytest +_CORE_WEB_PLUGIN_CAPABILITIES = { + "brave-free": (True, False, False), + "ddgs": (True, False, False), + "searxng": (True, False, False), + "exa": (True, True, False), + "parallel": (True, True, False), + "tavily": (True, True, True), + # firecrawl: search + extract + crawl. Crawl was originally + # disabled in the migration (fell through to a legacy inline + # path); the follow-up commit enabled it natively. + "firecrawl": (True, True, True), + # xai: search-only agentic web search via Grok Responses API. + "xai": (True, False, False), +} + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -48,6 +64,7 @@ def _clear_web_env(monkeypatch: pytest.MonkeyPatch) -> None: "TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN", "XAI_API_KEY", + "XAI_BASE_URL", ): monkeypatch.delenv(k, raising=False) @@ -71,40 +88,18 @@ def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None: class TestBundledPluginsRegister: - """All eight bundled web plugins discover and register correctly.""" + """Bundled web plugins discover and register correctly.""" - def test_all_seven_plugins_present_in_registry(self) -> None: + def test_core_plugins_present_in_registry(self) -> None: _ensure_plugins_loaded() from agent.web_search_registry import list_providers - names = sorted(p.name for p in list_providers()) - assert names == [ - "brave-free", - "ddgs", - "exa", - "firecrawl", - "parallel", - "searxng", - "tavily", - "xai", - ] + names = {p.name for p in list_providers()} + assert set(_CORE_WEB_PLUGIN_CAPABILITIES).issubset(names) @pytest.mark.parametrize( "plugin_name,expected_search,expected_extract,expected_crawl", - [ - ("brave-free", True, False, False), - ("ddgs", True, False, False), - ("searxng", True, False, False), - ("exa", True, True, False), - ("parallel", True, True, False), - ("tavily", True, True, True), - # firecrawl: search + extract + crawl. Crawl was originally - # disabled in the migration (fell through to a legacy inline - # path); the follow-up commit enabled it natively. - ("firecrawl", True, True, True), - # xai: search-only via Grok's agentic web_search tool. - ("xai", True, False, False), - ], + [(name, *caps) for name, caps in _CORE_WEB_PLUGIN_CAPABILITIES.items()], ) def test_capability_flags_match_spec( self, @@ -124,7 +119,7 @@ def test_capability_flags_match_spec( @pytest.mark.parametrize( "plugin_name", - ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"], + list(_CORE_WEB_PLUGIN_CAPABILITIES), ) def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None: _ensure_plugins_loaded() @@ -137,7 +132,7 @@ def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None: @pytest.mark.parametrize( "plugin_name", - ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"], + list(_CORE_WEB_PLUGIN_CAPABILITIES), ) def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None: """``get_setup_schema()`` returns a dict the picker can consume.""" @@ -227,6 +222,19 @@ def test_firecrawl_requires_either_key_or_url( monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002") assert p.is_available() is True + def test_xai_requires_api_key_or_oauth_token( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + p = get_provider("xai") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("XAI_API_KEY", "real") + assert p.is_available() is True + def test_ddgs_always_available_when_package_importable(self) -> None: """DDGS is the always-on fallback — no API key required. @@ -243,17 +251,6 @@ def test_ddgs_always_available_when_package_importable(self) -> None: # Truthy or falsy, just must not raise. _ = bool(p.is_available()) - def test_xai_requires_api_key_or_oauth(self, monkeypatch: pytest.MonkeyPatch) -> None: - """xAI needs XAI_API_KEY or OAuth tokens in auth.json.""" - _ensure_plugins_loaded() - from agent.web_search_registry import get_provider - - p = get_provider("xai") - assert p is not None - assert p.is_available() is False # no XAI_API_KEY, no auth.json - monkeypatch.setenv("XAI_API_KEY", "real") - assert p.is_available() is True - # --------------------------------------------------------------------------- # Registry resolution semantics (Option B — conservative smart fallback) @@ -470,7 +467,7 @@ def test_tavily_crawl_returns_error_dict_when_unconfigured(self) -> None: if result["results"]: assert "error" in result["results"][0] - def test_firecrawl_crawl_returns_error_dict_when_unconfigured(self): + def test_firecrawl_crawl_returns_error_dict_when_unconfigured(self) -> None: """firecrawl crawl is async (wraps SDK in to_thread); error must be surfaced via the per-page result shape, not raised.""" _ensure_plugins_loaded() @@ -488,15 +485,3 @@ def test_firecrawl_crawl_returns_error_dict_when_unconfigured(self): assert len(result["results"]) >= 1 assert "error" in result["results"][0] assert result["results"][0]["url"] == "https://example.com" - - def test_xai_search_returns_error_dict_when_unconfigured(self) -> None: - """xAI returns a typed error dict (no XAI_API_KEY).""" - _ensure_plugins_loaded() - from agent.web_search_registry import get_provider - - p = get_provider("xai") - assert p is not None - result = p.search("test", limit=5) - assert isinstance(result, dict) - assert result.get("success") is False - assert "error" in result diff --git a/tests/tools/test_vercel_sandbox_environment.py b/tests/tools/test_vercel_sandbox_environment.py index afeeb8cedf943..3a952e6fc35ae 100644 --- a/tests/tools/test_vercel_sandbox_environment.py +++ b/tests/tools/test_vercel_sandbox_environment.py @@ -16,6 +16,8 @@ import pytest +from hermes_constants import reset_hermes_home_override, set_hermes_home_override + class _FakeRunResult: def __init__(self, output: str | bytes = "", exit_code: int = 0): @@ -508,55 +510,67 @@ def test_create_restores_from_saved_snapshot( ): hermes_home = tmp_path / ".hermes" monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - vercel_module._store_snapshot("task-123", "snap_saved") - restored = _FakeSandbox(cwd="/restored") - vercel_sdk.create_side_effects.append(restored) - - env = make_env() - - assert env.cwd == "/restored" - assert vercel_sdk.create_kwargs[0]["source"] == { - "type": "snapshot", - "snapshot_id": "snap_saved", - } - assert vercel_module._load_snapshots() == {"task-123": "snap_saved"} + token = set_hermes_home_override(hermes_home) + try: + vercel_module._store_snapshot("task-123", "snap_saved") + restored = _FakeSandbox(cwd="/restored") + vercel_sdk.create_side_effects.append(restored) + + env = make_env() + + assert env.cwd == "/restored" + assert vercel_sdk.create_kwargs[0]["source"] == { + "type": "snapshot", + "snapshot_id": "snap_saved", + } + assert vercel_module._load_snapshots() == {"task-123": "snap_saved"} + finally: + reset_hermes_home_override(token) def test_restore_failure_prunes_snapshot_and_falls_back_to_fresh_sandbox( self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path ): hermes_home = tmp_path / ".hermes" monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - vercel_module._store_snapshot("task-123", "snap_stale") - fresh = _FakeSandbox(cwd="/fresh") - vercel_sdk.create_side_effects.extend( - [RuntimeError("snapshot missing"), fresh] - ) - - env = make_env() - - assert env.cwd == "/fresh" - assert vercel_sdk.create_kwargs[0]["source"] == { - "type": "snapshot", - "snapshot_id": "snap_stale", - } - assert "source" not in vercel_sdk.create_kwargs[1] - assert vercel_module._load_snapshots() == {} + token = set_hermes_home_override(hermes_home) + try: + vercel_module._store_snapshot("task-123", "snap_stale") + fresh = _FakeSandbox(cwd="/fresh") + vercel_sdk.create_side_effects.extend( + [RuntimeError("snapshot missing"), fresh] + ) + + env = make_env() + + assert env.cwd == "/fresh" + assert vercel_sdk.create_kwargs[0]["source"] == { + "type": "snapshot", + "snapshot_id": "snap_stale", + } + assert "source" not in vercel_sdk.create_kwargs[1] + assert vercel_module._load_snapshots() == {} + finally: + reset_hermes_home_override(token) def test_cleanup_stops_when_snapshot_fails_without_storing_metadata( self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path ): hermes_home = tmp_path / ".hermes" monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - env = make_env() - sandbox = vercel_sdk.current - sandbox.snapshot_side_effects.append(RuntimeError("snapshot failed")) + token = set_hermes_home_override(hermes_home) + try: + env = make_env() + sandbox = vercel_sdk.current + sandbox.snapshot_side_effects.append(RuntimeError("snapshot failed")) - env.cleanup() + env.cleanup() - assert len(sandbox.snapshot_calls) == 1 - assert len(sandbox.stop_calls) == 1 - assert sandbox.closed == 1 - assert vercel_module._load_snapshots() == {} + assert len(sandbox.snapshot_calls) == 1 + assert len(sandbox.stop_calls) == 1 + assert sandbox.closed == 1 + assert vercel_module._load_snapshots() == {} + finally: + reset_hermes_home_override(token) def test_non_persistent_cleanup_stops_without_snapshot( self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index d0b6a0f5de3e6..d9d31f1b8ed32 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -156,7 +156,22 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // treat the current resume target as part of the PTY identity and rebuild the // terminal session when it changes. const resumeParam = searchParams.get("resume"); - const channel = useMemo(() => generateChannelId(), [resumeParam]); + // SessionsPage may intentionally navigate to the *same* resume target again + // after a previous dashboard PTY has ended. React keeps ChatPage mounted + // persistently so ordinary tab switches preserve the live PTY, which also + // means a same-URL resume click would otherwise leave the stale terminal on + // screen forever. `resumeNonce` is a route-level "start a fresh PTY for this + // resume click" signal. It is folded into the opaque dashboard event channel + // id that is sent to the backend; the backend never interprets it as resume + // state or forwards it to the TUI child. + const resumeNonce = searchParams.get("resumeNonce"); + const channel = useMemo( + () => + ["chat", resumeParam ?? "new", resumeNonce ?? "0", generateChannelId()].join( + "_", + ), + [resumeParam, resumeNonce], + ); useEffect(() => { if (!resumeParam) return; @@ -650,7 +665,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { copyResetRef.current = null; } }; - }, [channel, resumeParam]); + }, [channel, resumeParam, resumeNonce]); // When the user returns to the chat tab (isActive: false → true), the // terminal host just transitioned from display:none to display:flex. diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 5e8f65f35f630..cc2f15fb405c3 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -51,6 +51,13 @@ import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; +function resumeClickNonce(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + const SOURCE_CONFIG: Record = { cli: { icon: Terminal, color: "text-primary" }, @@ -310,7 +317,11 @@ function SessionRow({ title={t.sessions.resumeInChat} onClick={(e) => { e.stopPropagation(); - navigate(`/chat?resume=${encodeURIComponent(session.id)}`); + const qs = new URLSearchParams({ + resume: session.id, + resumeNonce: resumeClickNonce(), + }); + navigate(`/chat?${qs.toString()}`); }} >