From 9160f10fc95bd97514efa49bf3bba311b6b21d90 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 10:27:50 -0500 Subject: [PATCH 1/2] fix(desktop): tsc clean before dev / build A stray tsc run can emit foo.js next to foo.ts under apps/shared/src or apps/desktop/src. .gitignore hides the artifact from git status, but Vite resolves extensionless imports .js-before-.ts, so the renderer silently runs the stale compiled copy. tsc -b . --clean already knows the emit graph and deletes matching outputs. Run it before vite in all dev scripts. This bit for real: a Jul 16 artifact of websocket-url.js predated the #68250 getGatewayWsUrl contract change ({ ok, wsUrl } IPC result), so its old 'if (fresh) return fresh' handed the whole result object to new WebSocket(), dialing ws://127.0.0.1:5174/[object%20Object] on every boot. The desktop app could never connect, and the failure survived reboots and cache wipes because the poison lived in src/. JsonRpcGatewayClient.connect() now rejects non-ws:// URLs with a readable error instead of letting new WebSocket() coerce an object into [object%20Object], so any future contract skew fails diagnosably. --- apps/desktop/package.json | 20 ++++--- .../lib/json-rpc-gateway-url-guard.test.ts | 59 +++++++++++++++++++ apps/shared/src/json-rpc-gateway.ts | 7 +++ 3 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/lib/json-rpc-gateway-url-guard.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 743269c10476..cb8cdcf76429 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,15 +11,19 @@ "node": "^20.19.0 || >=22.12.0" }, "scripts": { + "clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron", + "clean:e2e":"tsc --build tsconfig.e2e.json --clean", + "clean:renderer":"tsc --build tsconfig.json --clean ", + "clean:electron":"tsc --build tsconfig.electron.json --clean", "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", "dev:mock": "node scripts/dev-mock.mjs", - "dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174", - "dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", - "profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", - "profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174", + "dev:electron": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", + "profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "prebuild": "tsc -b . --clean", + "prebuild": "npm run clean", "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", "postbuild": "node scripts/assert-dist-built.mjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", @@ -51,9 +55,9 @@ "test": "vitest run", "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174", "check": "npm run typecheck && npm run test && npm run test:desktop:all", - "test:e2e": "playwright test e2e/", - "test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list", - "test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots" + "test:e2e": "npm run clean:e2e && playwright test e2e/", + "test:e2e:visual": "npm run clean:e2e && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- playwright test e2e/ --reporter=list", + "test:e2e:update-snapshots": "npm run clean:e2e && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- playwright test e2e/ --reporter=list --update-snapshots" }, "dependencies": { "@assistant-ui/react": "^0.14.23", diff --git a/apps/desktop/src/lib/json-rpc-gateway-url-guard.test.ts b/apps/desktop/src/lib/json-rpc-gateway-url-guard.test.ts new file mode 100644 index 000000000000..9989c669f5d4 --- /dev/null +++ b/apps/desktop/src/lib/json-rpc-gateway-url-guard.test.ts @@ -0,0 +1,59 @@ +// connect() must reject before WebSocket coerces garbage into +// `ws:///[object%20Object]` (#68250 stale-emit boot loop). + +import { JsonRpcGatewayClient } from '@hermes/shared' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +class FakeSocket { + static OPEN = 1 + readyState = 0 + addEventListener = vi.fn((type: string, handler: () => void) => { + if (type === 'open') { + setTimeout(() => { + this.readyState = FakeSocket.OPEN + handler() + }, 0) + } + }) + removeEventListener = vi.fn() + close = vi.fn() + send = vi.fn() +} + +describe('JsonRpcGatewayClient connect() URL guard', () => { + beforeEach(() => { + vi.stubGlobal('WebSocket', FakeSocket) // jsdom has none; class reads WebSocket.OPEN + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('rejects a non-string IPC result object', async () => { + const client = new JsonRpcGatewayClient() + await expect( + client.connect({ ok: true, wsUrl: 'ws://127.0.0.1:1/api/ws' } as unknown as string) + ).rejects.toThrow(/requires a ws:\/\/ or wss:\/\/ URL string, got type "object"/) + }) + + it('rejects a non-ws URL string', async () => { + const client = new JsonRpcGatewayClient() + await expect(client.connect('http://127.0.0.1:1234/api/ws')).rejects.toThrow( + /requires a ws:\/\/ or wss:\/\/ URL string/ + ) + }) + + it('keeps connection state idle on rejection', async () => { + const client = new JsonRpcGatewayClient() + await client.connect(undefined as unknown as string).catch(() => undefined) + expect(client.connectionState).toBe('idle') + }) + + it('accepts ws:// and wss://', async () => { + for (const url of ['ws://127.0.0.1:1234/api/ws?token=t', 'wss://gw.example.com/api/ws?ticket=t']) { + const client = new JsonRpcGatewayClient({ socketFactory: () => new FakeSocket() as unknown as WebSocket }) + await client.connect(url) + expect(client.connectionState).toBe('open') + } + }) +}) diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index cff91305f27f..de67806ddc4b 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -95,6 +95,13 @@ export class JsonRpcGatewayClient { } async connect(wsUrl: string): Promise { + // Refuse garbage; WebSocket coerces non-strings into + // `ws:///[object%20Object]` (#68250 stale-emit boot loop). + if (typeof wsUrl !== 'string' || !/^wss?:\/\//i.test(wsUrl)) { + const got = typeof wsUrl === 'string' ? JSON.stringify(wsUrl) : `type "${typeof wsUrl}"` + throw new Error(`gateway connect() requires a ws:// or wss:// URL string, got ${got}`) + } + if (this.socket?.readyState === WebSocket.OPEN || this.state === 'connecting') { return } From 02fa447f8e5cf0cd213ce58ad67a86ea2cd75413 Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 22 Jul 2026 14:31:53 -0400 Subject: [PATCH 2/2] fix(desktop): validate gateway WebSocket URLs --- .../lib/json-rpc-gateway-url-guard.test.ts | 6 ++++++ apps/shared/src/json-rpc-gateway.ts | 20 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/json-rpc-gateway-url-guard.test.ts b/apps/desktop/src/lib/json-rpc-gateway-url-guard.test.ts index 9989c669f5d4..7cedfe9c69c8 100644 --- a/apps/desktop/src/lib/json-rpc-gateway-url-guard.test.ts +++ b/apps/desktop/src/lib/json-rpc-gateway-url-guard.test.ts @@ -43,6 +43,12 @@ describe('JsonRpcGatewayClient connect() URL guard', () => { ) }) + it('rejects a malformed ws URL before opening a socket', async () => { + const client = new JsonRpcGatewayClient() + await expect(client.connect('ws://')).rejects.toThrow(/requires a ws:\/\/ or wss:\/\/ URL string/) + expect(client.connectionState).toBe('idle') + }) + it('keeps connection state idle on rejection', async () => { const client = new JsonRpcGatewayClient() await client.connect(undefined as unknown as string).catch(() => undefined) diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index de67806ddc4b..37714b8e2d4b 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -97,9 +97,25 @@ export class JsonRpcGatewayClient { async connect(wsUrl: string): Promise { // Refuse garbage; WebSocket coerces non-strings into // `ws:///[object%20Object]` (#68250 stale-emit boot loop). - if (typeof wsUrl !== 'string' || !/^wss?:\/\//i.test(wsUrl)) { + const invalidUrl = () => { const got = typeof wsUrl === 'string' ? JSON.stringify(wsUrl) : `type "${typeof wsUrl}"` - throw new Error(`gateway connect() requires a ws:// or wss:// URL string, got ${got}`) + + return new Error(`gateway connect() requires a ws:// or wss:// URL string, got ${got}`) + } + + if (typeof wsUrl !== 'string') { + throw invalidUrl() + } + + let url: URL + try { + url = new URL(wsUrl) + } catch { + throw invalidUrl() + } + + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw invalidUrl() } if (this.socket?.readyState === WebSocket.OPEN || this.state === 'connecting') {