From 7fb41174719907b7e3f38140e52f1a2d49e895bb Mon Sep 17 00:00:00 2001 From: PAW122 Date: Mon, 1 Jun 2026 23:19:44 +0200 Subject: [PATCH] fix(tui): support dashboard websocket attach on Node 20 Fix embedded dashboard TUI failures when the PTY process runs under Node 20, where global WebSocket may be unavailable. Fall back to undici's WebSocket constructor and cover the attach-mode behavior in gateway client tests. Also keeps the prior render-phase update fix for gateway event handler creation, preventing eager config RPC during React render. --- ui-tui/src/__tests__/gatewayClient.test.ts | 17 +++++++++-- ui-tui/src/app/createGatewayEventHandler.ts | 9 +++--- ui-tui/src/gatewayClient.ts | 32 ++++++++++++++++++--- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts index f1228e56fbeb..33bf8ad6e497 100644 --- a/ui-tui/src/__tests__/gatewayClient.test.ts +++ b/ui-tui/src/__tests__/gatewayClient.test.ts @@ -97,10 +97,13 @@ describe('GatewayClient websocket attach mode', () => { const originalWebSocket = globalThis.WebSocket let originalGatewayUrl: string | undefined let originalSidecarUrl: string | undefined + let originalDisableUndiciWebSocket: string | undefined beforeEach(() => { originalGatewayUrl = process.env.HERMES_TUI_GATEWAY_URL originalSidecarUrl = process.env.HERMES_TUI_SIDECAR_URL + originalDisableUndiciWebSocket = process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET + delete process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET FakeWebSocket.reset() ;(globalThis as { WebSocket?: unknown }).WebSocket = FakeWebSocket as unknown as typeof WebSocket }) @@ -118,6 +121,12 @@ describe('GatewayClient websocket attach mode', () => { process.env.HERMES_TUI_SIDECAR_URL = originalSidecarUrl } + if (originalDisableUndiciWebSocket === undefined) { + delete process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET + } else { + process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET = originalDisableUndiciWebSocket + } + FakeWebSocket.reset() if (originalWebSocket) { @@ -271,6 +280,7 @@ describe('GatewayClient websocket attach mode', () => { it('redacts query string secrets in attach failure logs and events', () => { process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=hunter2&channel=secret' + process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET = '1' delete (globalThis as { WebSocket?: unknown }).WebSocket const gw = new GatewayClient() @@ -301,7 +311,7 @@ describe('GatewayClient websocket attach mode', () => { const secretUrl = 'ws://gateway.test/api/ws?token=hunter2&channel=secret' process.env.HERMES_TUI_GATEWAY_URL = secretUrl - ;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingWebSocket extends FakeWebSocket { + ;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingWebSocket { constructor(url: string) { throw new TypeError(`Invalid URL: ${url}`) } @@ -328,11 +338,11 @@ describe('GatewayClient websocket attach mode', () => { process.env.HERMES_TUI_SIDECAR_URL = sidecarUrl ;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingSidecarWebSocket extends FakeWebSocket { constructor(url: string) { + super(url) + if (url.includes('/api/pub')) { throw new TypeError(`Invalid URL: ${url}`) } - - super(url) } } as unknown as typeof WebSocket @@ -363,6 +373,7 @@ describe('GatewayClient websocket attach mode', () => { expect(() => new URL(fixture)).toThrow() process.env.HERMES_TUI_GATEWAY_URL = fixture + process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET = '1' delete (globalThis as { WebSocket?: unknown }).WebSocket const gw = new GatewayClient() diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 987518a4460c..5fa9ceaee5f6 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -190,10 +190,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: agentsNudgedThisTurn = false } - // Kick off the config fetch eagerly at handler creation so the flag is - // resolved well before the first delegation of any real session (which - // only happens after gateway.ready + a user turn). - ensureAgentsNudgeConfig() + // Do not fetch the nudge config while creating the handler. This factory is + // called from React render via useMemo; a synchronous gateway failure can + // call sys() -> appendMessage() -> setHistoryItems() during render and + // trigger React #301. maybeNudgeAgents() fetches the flag lazily before the + // first delegation event, which is early enough for the feature. const refreshDelegationStatus = (force = false) => { const now = Date.now() diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 37bfa881aba5..c0e7d7123a9e 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -19,6 +19,26 @@ const WS_OPEN = 1 const WS_CLOSING = 2 const WS_CLOSED = 3 +type RuntimeWebSocketCtor = typeof globalThis.WebSocket + +const resolveRuntimeWebSocket = (): RuntimeWebSocketCtor | undefined => { + if (typeof globalThis.WebSocket !== 'undefined') { + return globalThis.WebSocket + } + + if (process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET === '1') { + return undefined + } + + try { + const undici = require('undici') as { WebSocket?: RuntimeWebSocketCtor } + + return undici.WebSocket + } catch { + return undefined + } +} + const truncateLine = (line: string) => line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line @@ -266,14 +286,16 @@ export class GatewayClient extends EventEmitter { return } - if (typeof WebSocket === 'undefined') { + const WebSocketCtor = resolveRuntimeWebSocket() + + if (!WebSocketCtor) { this.pushLog(`[sidecar] WebSocket unavailable; skipping mirror to ${redactUrl(this.sidecarUrl)}`) return } try { - const ws = new WebSocket(this.sidecarUrl) + const ws = new WebSocketCtor(this.sidecarUrl) this.sidecarWs = ws ws.addEventListener('close', () => { @@ -406,7 +428,9 @@ export class GatewayClient extends EventEmitter { const safeAttachUrl = redactUrl(attachUrl) this.startReadyTimer('websocket', safeAttachUrl) - if (typeof WebSocket === 'undefined') { + const WebSocketCtor = resolveRuntimeWebSocket() + + if (!WebSocketCtor) { const line = `[startup] WebSocket API unavailable; cannot attach to ${safeAttachUrl}` this.pushLog(line) @@ -417,7 +441,7 @@ export class GatewayClient extends EventEmitter { } try { - const ws = new WebSocket(attachUrl) + const ws = new WebSocketCtor(attachUrl) let settled = false this.ws = ws