From fe1606ab39cb85926f51302b02a062238c5afae4 Mon Sep 17 00:00:00 2001 From: Auto Date: Sun, 19 Apr 2026 13:06:57 +0200 Subject: [PATCH 1/5] 0.1.22 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c0b98f25..9c547ddf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autoforge-ai", - "version": "0.1.21", + "version": "0.1.22", "description": "Autonomous coding agent with web UI - build complete apps with AI", "license": "AGPL-3.0", "bin": { From 6675660d0f93a8c27f944e7c9dd4d35e3fc4ba83 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 13:07:47 +0200 Subject: [PATCH 2/5] Fix #235: Validate Origin on all WebSocket handshakes to block CSWSH Starlette's http middleware never runs for WebSocket scopes, so the localhost guard did not protect any WS route and any web page could hijack the terminal PTY socket (CWE-306). Adds a pure-ASGI WebSocketOriginMiddleware that rejects handshakes from non-localhost (or, in remote mode, non-matching-Host) origins with close code 4403 before the route handler runs, covering all five WS endpoints. Co-Authored-By: Claude Fable 5 --- server/main.py | 8 ++ server/utils/ws_security.py | 142 ++++++++++++++++++++++++++++++++++++ test_ws_security.py | 120 ++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 server/utils/ws_security.py create mode 100644 test_ws_security.py diff --git a/server/main.py b/server/main.py index 803c1072..7577ad5a 100644 --- a/server/main.py +++ b/server/main.py @@ -53,6 +53,7 @@ from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks from .services.scheduler_service import cleanup_scheduler, get_scheduler from .services.terminal_manager import cleanup_all_terminals +from .utils.ws_security import WebSocketOriginMiddleware from .websocket import project_websocket # Paths @@ -142,6 +143,13 @@ async def lifespan(app: FastAPI): # Security Middleware # ============================================================================ +# WebSocket Origin validation (CSWSH protection). Starlette's @app.middleware("http") +# never runs for WebSocket handshakes, so the require_localhost guard below does not +# cover WS routes. Browsers do not enforce same-origin on WebSocket connects either, +# so without this check any web page could hijack the terminal/chat sockets. +# Registered unconditionally so it applies even when AUTOFORGE_ALLOW_REMOTE=1. +app.add_middleware(WebSocketOriginMiddleware, allow_remote=ALLOW_REMOTE) + if not ALLOW_REMOTE: @app.middleware("http") async def require_localhost(request: Request, call_next): diff --git a/server/utils/ws_security.py b/server/utils/ws_security.py new file mode 100644 index 00000000..308f5ed0 --- /dev/null +++ b/server/utils/ws_security.py @@ -0,0 +1,142 @@ +""" +WebSocket Origin Validation +=========================== + +Starlette's ``@app.middleware("http")`` decorator only runs for HTTP requests, +so the ``require_localhost`` guard in ``server/main.py`` never sees WebSocket +handshakes. Browsers also do not enforce same-origin policy on WebSocket +connections, which left every WS endpoint (including the interactive PTY +terminal) open to cross-site WebSocket hijacking (CSWSH): any malicious web +page the user visited could open ``ws://127.0.0.1:8888/api/terminal/ws/...`` +and drive a shell (CWE-306). + +This module provides a pure-ASGI middleware that validates the ``Origin`` +header on every WebSocket handshake before the route handler runs. Handshakes +from disallowed origins are rejected with close code 4403 and never reach the +application, so no PTY/session is ever created. + +Non-browser clients (CLI tools, native apps) do not send an ``Origin`` header +and are allowed through: the CSWSH threat model is browser-only, and raw +socket access is governed by the localhost bind / ``AUTOFORGE_ALLOW_REMOTE``. +""" + +import logging +from collections.abc import Awaitable, Callable, MutableMapping +from typing import Any +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# ASGI type aliases (kept loose to avoid a hard dependency on starlette.types) +Scope = MutableMapping[str, Any] +Message = MutableMapping[str, Any] +Receive = Callable[[], Awaitable[Message]] +Send = Callable[[Message], Awaitable[None]] + +# WebSocket close code used when rejecting a disallowed origin. +# 4000-4999 is the range reserved for application-specific codes. +WS_CLOSE_FORBIDDEN = 4403 + +_LOCALHOST_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"}) + + +def _hostname_from_origin(origin: str) -> str | None: + """Extract the lowercase hostname from an Origin header value.""" + try: + return urlparse(origin).hostname + except ValueError: + return None + + +def _hostname_from_host(host: str) -> str | None: + """Extract the lowercase hostname from a Host header value (strips port).""" + try: + # Prefix with // so urlparse treats the value as a netloc + # (handles bracketed IPv6 literals like [::1]:8888). + return urlparse(f"//{host}").hostname + except ValueError: + return None + + +def is_allowed_ws_origin(origin: str | None, allow_remote: bool, host: str | None = None) -> bool: + """Decide whether a WebSocket handshake with the given Origin is allowed. + + Rules: + + * No ``Origin`` header (non-browser client such as a CLI or native app): + allowed. CSWSH is a browser-only threat, and non-browser network access + is already gated by the localhost bind / AUTOFORGE_ALLOW_REMOTE opt-in. + * Origin hostname is a localhost variant (``localhost``, ``127.0.0.1``, + ``::1``): allowed regardless of scheme or port. This covers the Vite + dev server (5173) and the production server (8888). + * ``allow_remote`` is True: additionally allow origins whose hostname + matches the host the browser connected to (the ``Host`` header), so the + remotely-served UI keeps working while foreign pages are still rejected. + * Everything else: rejected. + + Args: + origin: Value of the Origin header, or None if absent. + allow_remote: Whether AUTOFORGE_ALLOW_REMOTE is enabled. + host: Value of the Host header (used only when allow_remote is True). + + Returns: + True if the handshake should proceed, False if it must be rejected. + """ + if not origin: + return True + + hostname = _hostname_from_origin(origin) + if not hostname: + return False + + if hostname in _LOCALHOST_HOSTNAMES: + return True + + if allow_remote and host: + host_name = _hostname_from_host(host) + return host_name is not None and hostname == host_name + + return False + + +class WebSocketOriginMiddleware: + """Pure-ASGI middleware that rejects WS handshakes from disallowed origins. + + Implemented as raw ASGI (not ``@app.middleware("http")``) because + Starlette's http middleware decorator is never invoked for scope type + ``websocket`` -- which is exactly the gap that left the WS endpoints + unprotected. HTTP and lifespan scopes pass through untouched. + """ + + def __init__(self, app: Any, allow_remote: bool = False) -> None: + self.app = app + self.allow_remote = allow_remote + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "websocket": + await self.app(scope, receive, send) + return + + origin: str | None = None + host: str | None = None + for name, value in scope.get("headers", []): + if name == b"origin": + origin = value.decode("latin-1") + elif name == b"host": + host = value.decode("latin-1") + + if not is_allowed_ws_origin(origin, self.allow_remote, host): + logger.warning( + "Rejected WebSocket handshake from disallowed origin %r (path=%s)", + origin, + scope.get("path"), + ) + # Refuse the handshake without ever accepting: consume the + # connect event, then close. The route handler never runs, + # so no terminal/chat session is created. + message = await receive() + if message["type"] == "websocket.connect": + await send({"type": "websocket.close", "code": WS_CLOSE_FORBIDDEN}) + return + + await self.app(scope, receive, send) diff --git a/test_ws_security.py b/test_ws_security.py new file mode 100644 index 00000000..24f16c25 --- /dev/null +++ b/test_ws_security.py @@ -0,0 +1,120 @@ +""" +Unit tests for WebSocket Origin validation (CSWSH protection). + +Tests is_allowed_ws_origin() and WebSocketOriginMiddleware from +server/utils/ws_security.py. +""" + +import asyncio +import unittest + +from server.utils.ws_security import ( + WS_CLOSE_FORBIDDEN, + WebSocketOriginMiddleware, + is_allowed_ws_origin, +) + + +class TestIsAllowedWsOrigin(unittest.TestCase): + """Tests for is_allowed_ws_origin().""" + + def test_missing_origin_allowed(self): + """Non-browser clients send no Origin header and must be allowed.""" + assert is_allowed_ws_origin(None, False) is True + assert is_allowed_ws_origin("", False) is True + assert is_allowed_ws_origin(None, True) is True + + def test_localhost_origins_allowed(self): + """Localhost variants are allowed regardless of scheme or port.""" + assert is_allowed_ws_origin("http://localhost:5173", False) is True + assert is_allowed_ws_origin("http://localhost:8888", False) is True + assert is_allowed_ws_origin("http://127.0.0.1:8888", False) is True + assert is_allowed_ws_origin("https://127.0.0.1", False) is True + assert is_allowed_ws_origin("http://[::1]:8888", False) is True + + def test_foreign_origins_rejected(self): + """Non-localhost origins are rejected in the default (local) mode.""" + assert is_allowed_ws_origin("http://evil.com", False) is False + assert is_allowed_ws_origin("https://evil.com:8888", False) is False + assert is_allowed_ws_origin("http://localhost.evil.com", False) is False + assert is_allowed_ws_origin("http://192.168.1.50:8888", False) is False + + def test_garbage_origin_rejected(self): + """Origins without a parseable hostname are rejected.""" + assert is_allowed_ws_origin("null", False) is False + assert is_allowed_ws_origin("not a url", False) is False + + def test_remote_mode_host_match_allowed(self): + """In remote mode, origins matching the connected Host are allowed.""" + assert is_allowed_ws_origin("http://192.168.1.50:8888", True, host="192.168.1.50:8888") is True + assert is_allowed_ws_origin("http://myserver:8888", True, host="myserver:8888") is True + # Localhost still allowed too + assert is_allowed_ws_origin("http://localhost:5173", True, host="192.168.1.50:8888") is True + + def test_remote_mode_foreign_origin_rejected(self): + """In remote mode, origins not matching the Host are still rejected.""" + assert is_allowed_ws_origin("http://evil.com", True, host="192.168.1.50:8888") is False + assert is_allowed_ws_origin("http://evil.com", True, host=None) is False + + +class _FakeApp: + """Minimal downstream ASGI app that records whether it was called.""" + + def __init__(self): + self.called = False + + async def __call__(self, scope, receive, send): + self.called = True + + +def _run_ws_handshake(middleware, headers): + """Drive a websocket scope through the middleware; return sent messages.""" + sent = [] + + async def receive(): + return {"type": "websocket.connect"} + + async def send(message): + sent.append(message) + + scope = {"type": "websocket", "path": "/api/terminal/ws/proj/term", "headers": headers} + asyncio.run(middleware(scope, receive, send)) + return sent + + +class TestWebSocketOriginMiddleware(unittest.TestCase): + """Tests for the ASGI middleware behavior.""" + + def test_http_scope_passes_through(self): + """HTTP requests are never touched by this middleware.""" + app = _FakeApp() + middleware = WebSocketOriginMiddleware(app, allow_remote=False) + asyncio.run(middleware({"type": "http", "headers": []}, None, None)) + assert app.called is True + + def test_allowed_origin_reaches_app(self): + """Localhost-origin handshakes reach the route handler.""" + app = _FakeApp() + middleware = WebSocketOriginMiddleware(app, allow_remote=False) + sent = _run_ws_handshake(middleware, [(b"origin", b"http://localhost:8888")]) + assert app.called is True + assert sent == [] + + def test_missing_origin_reaches_app(self): + """Handshakes without an Origin header (non-browser) reach the app.""" + app = _FakeApp() + middleware = WebSocketOriginMiddleware(app, allow_remote=False) + _run_ws_handshake(middleware, []) + assert app.called is True + + def test_foreign_origin_rejected_before_app(self): + """Cross-site handshakes are closed with 4403 and never reach the app.""" + app = _FakeApp() + middleware = WebSocketOriginMiddleware(app, allow_remote=False) + sent = _run_ws_handshake(middleware, [(b"origin", b"http://evil.com")]) + assert app.called is False + assert sent == [{"type": "websocket.close", "code": WS_CLOSE_FORBIDDEN}] + + +if __name__ == "__main__": + unittest.main() From 480dc66872a55d20fea942b70c426dd2bf2462c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 13:12:54 +0200 Subject: [PATCH 3/5] Fix #233: Enforce read-only assistant chat at tool-permission level The assistant and expand chat sessions only forbade code modification via prompt text; with permission_mode=bypassPermissions, Write/Edit/Bash remained fully callable. Add disallowed_tools (which actually removes tools from the model) plus a permissions deny list in the generated settings, block coding- agent-only feature MCP tools, and strengthen the assistant system prompt to direct users to create features for the coding agents instead. Co-Authored-By: Claude Fable 5 --- server/services/assistant_chat_session.py | 26 ++++++++++++++++++++--- server/services/expand_chat_session.py | 4 +++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 275a900d..382010c4 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -68,6 +68,24 @@ "WebSearch", ] +# Tools explicitly removed from the assistant's toolset. +# allowed_tools only pre-approves permissions; disallowed_tools actually +# removes tools from the model, which matters with bypassPermissions. +DISALLOWED_ASSISTANT_TOOLS = [ + # File mutation / execution + "Write", + "Edit", + "MultiEdit", + "NotebookEdit", + "Bash", + # Feature MCP tools reserved for coding agents + "mcp__features__feature_mark_passing", + "mcp__features__feature_mark_failing", + "mcp__features__feature_mark_in_progress", + "mcp__features__feature_claim_and_get", + "mcp__features__feature_clear_in_progress", +] + def get_system_prompt(project_name: str, project_dir: Path) -> str: """Generate the system prompt for the assistant with project context.""" @@ -105,11 +123,11 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: ## What You CANNOT Do -- Modify, create, or delete source code files -- Mark features as passing (that requires actual implementation by the coding agent) +- Modify, create, or delete source code files -- you have NO Write/Edit/Bash tools +- Mark features as passing or failing (that is the coding agent's job after real implementation) - Run bash commands or execute code -If the user asks you to modify code, explain that you're a project assistant and they should use the main coding agent for implementation. +**You must NEVER implement code yourself.** Even if the user pastes code, asks for a "quick fix", or insists -- do not attempt to write or edit files. Implementation is done exclusively by the coding agents. When the user wants code changed or a bug fixed, create a feature for it with `feature_create` (or `feature_create_bulk`) describing the change, then tell the user to start the coding agent to implement it. ## Project Specification @@ -234,6 +252,7 @@ async def start(self) -> AsyncGenerator[dict, None]: "permissions": { "defaultMode": "bypassPermissions", # Read-only, no dangerous ops "allow": permissions_list, + "deny": ["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"], }, } from autoforge_paths import get_claude_assistant_settings_path @@ -288,6 +307,7 @@ async def start(self) -> AsyncGenerator[dict, None]: # This avoids Windows command line length limit (~8191 chars) setting_sources=["project"], allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], + disallowed_tools=DISALLOWED_ASSISTANT_TOOLS, mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime permission_mode="bypassPermissions", max_turns=100, diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index cb576f1d..575e7420 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -135,7 +135,7 @@ async def start(self) -> AsyncGenerator[dict, None]: # Create temporary security settings file (unique per session to avoid conflicts) # Note: permission_mode="bypassPermissions" is safe here because: - # 1. Only Read/Glob file tools are allowed (no Write/Edit) + # 1. Write/Edit/Bash are removed via disallowed_tools and the "deny" list below # 2. MCP tools are restricted to feature creation only # 3. No Bash access - cannot execute arbitrary commands security_settings = { @@ -147,6 +147,7 @@ async def start(self) -> AsyncGenerator[dict, None]: "Glob(./**)", *EXPAND_FEATURE_TOOLS, ], + "deny": ["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"], }, } from autoforge_paths import get_expand_settings_path @@ -196,6 +197,7 @@ async def start(self) -> AsyncGenerator[dict, None]: "WebSearch", *EXPAND_FEATURE_TOOLS, ], + disallowed_tools=["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"], mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime permission_mode="bypassPermissions", max_turns=100, From 0e3ee62b76e2a828f51890d823f4193fbcaa171d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 13:16:28 +0200 Subject: [PATCH 4/5] Fix #221: Surface actionable errors for Claude client init timeouts and enforce Python 3.11+ Fail fast with a clear message in server/main.py and start.py when running on Python < 3.11 (previously the unsupported interpreter only manifested as an opaque SDK 'Control request timeout: initialize'). Add a shared format_client_init_error() helper in chat_constants.py and use it in the expand, assistant, and spec chat sessions so handshake timeouts yield actionable guidance (auth, CLI install, antivirus, timeout env var) over the WebSocket instead of a bare 'Failed to initialize Claude'. Co-Authored-By: Claude Fable 5 --- server/main.py | 12 ++++++++++ server/services/assistant_chat_session.py | 3 ++- server/services/chat_constants.py | 29 +++++++++++++++++++++++ server/services/expand_chat_session.py | 5 ++-- server/services/spec_chat_session.py | 3 ++- start.py | 12 ++++++++++ 6 files changed, 60 insertions(+), 4 deletions(-) diff --git a/server/main.py b/server/main.py index 7577ad5a..1ea36976 100644 --- a/server/main.py +++ b/server/main.py @@ -14,6 +14,18 @@ from contextlib import asynccontextmanager from pathlib import Path +# Fail fast on unsupported Python versions. Older interpreters surface as +# opaque Claude SDK errors (e.g. "Control request timeout: initialize"). +if sys.version_info < (3, 11): + sys.exit( + "ERROR: AutoForge requires Python 3.11 or newer " + f"(you are running Python {sys.version.split()[0]}). " + "Older versions cause opaque Claude SDK errors such as " + "'Control request timeout: initialize'. " + "Recreate your virtual environment with Python 3.11+ " + "(python3.11 -m venv venv && pip install -r requirements.txt)." + ) + # Fix for Windows subprocess support in asyncio if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 382010c4..1a16606d 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -28,6 +28,7 @@ from .chat_constants import ( ROOT_DIR, check_rate_limit_error, + format_client_init_error, safe_receive_response, ) @@ -322,7 +323,7 @@ async def start(self) -> AsyncGenerator[dict, None]: logger.info("Claude client ready") except Exception as e: logger.exception("Failed to create Claude client") - yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"} + yield {"type": "error", "content": format_client_init_error(e)} return # Send initial greeting only for NEW conversations diff --git a/server/services/chat_constants.py b/server/services/chat_constants.py index 2e832e24..68e70e96 100644 --- a/server/services/chat_constants.py +++ b/server/services/chat_constants.py @@ -67,6 +67,35 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]: return False, None +def format_client_init_error(exc: Exception) -> str: + """Build an actionable error message for Claude client startup failures. + + The Claude Agent SDK raises an opaque ``Control request timeout: + initialize`` when the Claude CLI subprocess never completes the + initialize handshake (auth problems, broken/missing CLI, PATH issues, + unsupported Python, antivirus interference, slow machines). Translate + that into concrete guidance instead of surfacing the raw error. + """ + if "Control request timeout" in str(exc): + msg = ( + "Claude client timed out during startup (the Claude CLI did not " + "respond to the initialize handshake). Common causes:\n" + "- Claude CLI not authenticated: run `claude login` or set ANTHROPIC_API_KEY\n" + "- Claude CLI not installed or outdated: run `npm install -g @anthropic-ai/claude-code`\n" + "- Antivirus/firewall blocking the CLI subprocess (common on Windows)\n" + "- Slow machine: raise the timeout by setting the environment variable " + "CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=120000 before starting AutoForge" + ) + if sys.version_info < (3, 11): + py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + msg = ( + f"You are running Python {py}, but AutoForge requires Python 3.11+. " + "Recreate your virtual environment with Python 3.11+ and try again.\n\n" + msg + ) + return msg + return f"Failed to initialize Claude: {exc}" + + async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator: """Wrap ``client.receive_response()`` to skip ``MessageParseError``. diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 575e7420..66edb94e 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -27,6 +27,7 @@ ROOT_DIR, build_attachment_content_blocks, check_rate_limit_error, + format_client_init_error, make_multimodal_message, safe_receive_response, ) @@ -208,11 +209,11 @@ async def start(self) -> AsyncGenerator[dict, None]: ) await self.client.__aenter__() self._client_entered = True - except Exception: + except Exception as e: logger.exception("Failed to create Claude client") yield { "type": "error", - "content": "Failed to initialize Claude" + "content": format_client_init_error(e) } return diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 377640c4..0788b13a 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -24,6 +24,7 @@ ROOT_DIR, build_attachment_content_blocks, check_rate_limit_error, + format_client_init_error, make_multimodal_message, safe_receive_response, ) @@ -185,7 +186,7 @@ async def start(self) -> AsyncGenerator[dict, None]: logger.exception("Failed to create Claude client") yield { "type": "error", - "content": f"Failed to initialize Claude: {str(e)}" + "content": format_client_init_error(e) } return diff --git a/start.py b/start.py index f6aee659..9a5007b0 100644 --- a/start.py +++ b/start.py @@ -13,6 +13,18 @@ import sys from pathlib import Path +# Fail fast on unsupported Python versions. Older interpreters surface as +# opaque Claude SDK errors (e.g. "Control request timeout: initialize"). +if sys.version_info < (3, 11): + sys.exit( + "ERROR: AutoForge requires Python 3.11 or newer " + f"(you are running Python {sys.version.split()[0]}). " + "Older versions cause opaque Claude SDK errors such as " + "'Control request timeout: initialize'. " + "Recreate your virtual environment with Python 3.11+ " + "(python3.11 -m venv venv && pip install -r requirements.txt)." + ) + from dotenv import load_dotenv from auth import is_auth_error, print_auth_error_help From 9f78299cbaa65d389c76757b20375d1722d95991 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 13:20:25 +0200 Subject: [PATCH 5/5] Fix #215: Add WebSocket heartbeat, staleness detection, and wake-triggered reconnect The UI socket had a fire-and-forget ping with no dead-connection detection, timer-only reconnects that browsers throttle in idle/background tabs, and a cleanup path whose orphaned onclose could schedule zombie reconnects. The hook now tracks last inbound message time, force-closes stale sockets (90s) from a 20s heartbeat, reconnects immediately on visibilitychange/online, and detaches handlers on intentional close. Co-Authored-By: Claude Fable 5 --- ui/src/hooks/useWebSocket.ts | 81 ++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 003dcd86..cd891b9b 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -62,6 +62,8 @@ interface WebSocketState { const MAX_LOGS = 100 // Keep last 100 log lines const MAX_ACTIVITY = 20 // Keep last 20 activity items const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent +const HEARTBEAT_INTERVAL_MS = 20_000 // App-level ping cadence (well inside common 60s proxy idle timeouts) +const STALE_TIMEOUT_MS = 90_000 // No inbound traffic for this long => treat connection as dead export function useProjectWebSocket(projectName: string | null) { const [state, setState] = useState({ @@ -85,25 +87,50 @@ export function useProjectWebSocket(projectName: string | null) { const wsRef = useRef(null) const reconnectTimeoutRef = useRef(null) const reconnectAttempts = useRef(0) + const lastMessageAtRef = useRef(Date.now()) const connect = useCallback(() => { if (!projectName) return + // Cancel any pending reconnect so we never stack duplicate attempts + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + + // Guard against duplicate sockets + if ( + wsRef.current && + (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING) + ) { + return + } + // Build WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host const wsUrl = `${protocol}//${host}/ws/projects/${encodeURIComponent(projectName)}` try { + // Detach any old socket so its onclose can't schedule a stale reconnect + if (wsRef.current) { + wsRef.current.onclose = null + wsRef.current.onerror = null + wsRef.current.onmessage = null + wsRef.current.close() + } + const ws = new WebSocket(wsUrl) wsRef.current = ws ws.onopen = () => { + lastMessageAtRef.current = Date.now() setState(prev => ({ ...prev, isConnected: true })) reconnectAttempts.current = 0 } ws.onmessage = (event) => { + lastMessageAtRef.current = Date.now() try { const message: WSMessage = JSON.parse(event.data) @@ -385,11 +412,19 @@ export function useProjectWebSocket(projectName: string | null) { } }, [projectName]) - // Send ping to keep connection alive - const sendPing = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: 'ping' })) + // Heartbeat: detect dead connections and keep live ones alive. + // Any inbound message (including the server's pong) refreshes lastMessageAtRef. + const heartbeat = useCallback(() => { + if (wsRef.current?.readyState !== WebSocket.OPEN) return + + if (Date.now() - lastMessageAtRef.current > STALE_TIMEOUT_MS) { + // Half-open socket (sleep/wake, network change, proxy idle drop): + // send() won't throw, so force-close to trigger the onclose reconnect path + wsRef.current.close() + return } + + wsRef.current.send(JSON.stringify({ type: 'ping' })) }, []) // Clear celebration and show next one from queue if available @@ -430,6 +465,9 @@ export function useProjectWebSocket(projectName: string | null) { if (!projectName) { // Disconnect if no project if (wsRef.current) { + wsRef.current.onclose = null + wsRef.current.onerror = null + wsRef.current.onmessage = null wsRef.current.close() wsRef.current = null } @@ -438,20 +476,49 @@ export function useProjectWebSocket(projectName: string | null) { connect() - // Ping every 30 seconds - const pingInterval = setInterval(sendPing, 30000) + // App-level heartbeat (protects against proxy idle timeouts and detects dead sockets) + const pingInterval = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS) + + // Reconnect immediately when the tab becomes visible or the network comes back, + // instead of waiting for a background-throttled reconnect timer + const wakeUp = () => { + if (document.visibilityState === 'hidden') return + const ws = wsRef.current + if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + reconnectAttempts.current = 0 + connect() + } else if (ws.readyState === WebSocket.OPEN && Date.now() - lastMessageAtRef.current > STALE_TIMEOUT_MS) { + ws.close() // dead after sleep/wake — onclose reconnects immediately + } else if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) // probe liveness right away + } + } + document.addEventListener('visibilitychange', wakeUp) + window.addEventListener('online', wakeUp) return () => { clearInterval(pingInterval) + document.removeEventListener('visibilitychange', wakeUp) + window.removeEventListener('online', wakeUp) if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null } + reconnectAttempts.current = 0 if (wsRef.current) { + // Detach handlers so this intentional close can't schedule a stale reconnect + wsRef.current.onclose = null + wsRef.current.onerror = null + wsRef.current.onmessage = null wsRef.current.close() wsRef.current = null } } - }, [projectName, connect, sendPing]) + }, [projectName, connect, heartbeat]) // Defense-in-depth: cleanup stale agents for users who leave UI open for hours // This catches edge cases where completion messages are missed