From 284241c1b7eba6c490ab87e0a8293b8c254fdac6 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 5 Feb 2026 04:44:11 -0800 Subject: [PATCH 01/26] feat: add WSS support for HTTPS environments When localhost runs behind SSL (e.g., using localias), the relay now supports secure WebSocket connections: - Browser client auto-detects protocol from window.location.protocol - Relay server can run in HTTPS mode with auto-generated mkcert certs - All providers accept --secure flag to enable WSS connections Closes #156 --- packages/provider-amp/src/cli.ts | 3 +- packages/provider-amp/src/server.ts | 4 +- packages/provider-claude-code/src/cli.ts | 3 +- packages/provider-claude-code/src/server.ts | 4 +- packages/provider-codex/src/cli.ts | 3 +- packages/provider-codex/src/server.ts | 4 +- packages/provider-cursor/src/cli.ts | 3 +- packages/provider-cursor/src/server.ts | 4 +- packages/provider-droid/src/cli.ts | 3 +- packages/provider-droid/src/server.ts | 4 +- packages/provider-gemini/src/cli.ts | 3 +- packages/provider-gemini/src/server.ts | 4 +- packages/provider-opencode/src/cli.ts | 3 +- packages/provider-opencode/src/server.ts | 4 +- packages/relay/src/client.ts | 8 +- packages/relay/src/connection.ts | 52 +++++++--- packages/relay/src/mkcert.ts | 101 ++++++++++++++++++++ packages/relay/src/server.ts | 33 ++++++- 18 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 packages/relay/src/mkcert.ts diff --git a/packages/provider-amp/src/cli.ts b/packages/provider-amp/src/cli.ts index f9fbaef09..00e13f1cf 100644 --- a/packages/provider-amp/src/cli.ts +++ b/packages/provider-amp/src/cli.ts @@ -6,8 +6,9 @@ import { dirname, join } from "node:path"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); +const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath], { +const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, stdio: "inherit", }); diff --git a/packages/provider-amp/src/server.ts b/packages/provider-amp/src/server.ts index 72c2e0fb1..b4717d8d8 100644 --- a/packages/provider-amp/src/server.ts +++ b/packages/provider-amp/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { ampAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=amp&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: ampAgentHandler }); +connectRelay({ handler: ampAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-claude-code/src/cli.ts b/packages/provider-claude-code/src/cli.ts index f9fbaef09..00e13f1cf 100644 --- a/packages/provider-claude-code/src/cli.ts +++ b/packages/provider-claude-code/src/cli.ts @@ -6,8 +6,9 @@ import { dirname, join } from "node:path"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); +const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath], { +const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, stdio: "inherit", }); diff --git a/packages/provider-claude-code/src/server.ts b/packages/provider-claude-code/src/server.ts index 3032987ac..a12c7a42c 100644 --- a/packages/provider-claude-code/src/server.ts +++ b/packages/provider-claude-code/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { claudeAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=claude-code&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: claudeAgentHandler }); +connectRelay({ handler: claudeAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-codex/src/cli.ts b/packages/provider-codex/src/cli.ts index f9fbaef09..00e13f1cf 100644 --- a/packages/provider-codex/src/cli.ts +++ b/packages/provider-codex/src/cli.ts @@ -6,8 +6,9 @@ import { dirname, join } from "node:path"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); +const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath], { +const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, stdio: "inherit", }); diff --git a/packages/provider-codex/src/server.ts b/packages/provider-codex/src/server.ts index 4c50f6690..bca83b16a 100644 --- a/packages/provider-codex/src/server.ts +++ b/packages/provider-codex/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { codexAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=codex&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: codexAgentHandler }); +connectRelay({ handler: codexAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-cursor/src/cli.ts b/packages/provider-cursor/src/cli.ts index f9fbaef09..00e13f1cf 100644 --- a/packages/provider-cursor/src/cli.ts +++ b/packages/provider-cursor/src/cli.ts @@ -6,8 +6,9 @@ import { dirname, join } from "node:path"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); +const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath], { +const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, stdio: "inherit", }); diff --git a/packages/provider-cursor/src/server.ts b/packages/provider-cursor/src/server.ts index 6bed4cd94..3b45b8cc4 100644 --- a/packages/provider-cursor/src/server.ts +++ b/packages/provider-cursor/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { cursorAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=cursor&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: cursorAgentHandler }); +connectRelay({ handler: cursorAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-droid/src/cli.ts b/packages/provider-droid/src/cli.ts index f9fbaef09..00e13f1cf 100644 --- a/packages/provider-droid/src/cli.ts +++ b/packages/provider-droid/src/cli.ts @@ -6,8 +6,9 @@ import { dirname, join } from "node:path"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); +const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath], { +const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, stdio: "inherit", }); diff --git a/packages/provider-droid/src/server.ts b/packages/provider-droid/src/server.ts index ce6e557b0..8fae72887 100644 --- a/packages/provider-droid/src/server.ts +++ b/packages/provider-droid/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { droidAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=droid&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: droidAgentHandler }); +connectRelay({ handler: droidAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-gemini/src/cli.ts b/packages/provider-gemini/src/cli.ts index f9fbaef09..00e13f1cf 100644 --- a/packages/provider-gemini/src/cli.ts +++ b/packages/provider-gemini/src/cli.ts @@ -6,8 +6,9 @@ import { dirname, join } from "node:path"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); +const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath], { +const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, stdio: "inherit", }); diff --git a/packages/provider-gemini/src/server.ts b/packages/provider-gemini/src/server.ts index cdaac3730..b17b2feff 100644 --- a/packages/provider-gemini/src/server.ts +++ b/packages/provider-gemini/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { geminiAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=gemini&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: geminiAgentHandler }); +connectRelay({ handler: geminiAgentHandler, secure: isSecureConnection }); diff --git a/packages/provider-opencode/src/cli.ts b/packages/provider-opencode/src/cli.ts index f9fbaef09..00e13f1cf 100644 --- a/packages/provider-opencode/src/cli.ts +++ b/packages/provider-opencode/src/cli.ts @@ -6,8 +6,9 @@ import { dirname, join } from "node:path"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); +const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath], { +const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, stdio: "inherit", }); diff --git a/packages/provider-opencode/src/server.ts b/packages/provider-opencode/src/server.ts index d2fc38b06..93d2b9405 100644 --- a/packages/provider-opencode/src/server.ts +++ b/packages/provider-opencode/src/server.ts @@ -2,8 +2,10 @@ import { connectRelay } from "@react-grab/relay"; import { openCodeAgentHandler } from "./handler.js"; +const isSecureConnection = process.argv.includes("--secure"); + fetch( `https://www.react-grab.com/api/version?source=opencode&t=${Date.now()}`, ).catch(() => {}); -connectRelay({ handler: openCodeAgentHandler }); +connectRelay({ handler: openCodeAgentHandler, secure: isSecureConnection }); diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index 1872ef110..f8b52bf70 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -30,10 +30,16 @@ interface RelayClientOptions { token?: string; } +const getDefaultWebSocketUrl = (): string => { + const isSecure = + typeof window !== "undefined" && window.location.protocol === "https:"; + return `${isSecure ? "wss" : "ws"}://localhost:${DEFAULT_RELAY_PORT}`; +}; + export const createRelayClient = ( options: RelayClientOptions = {}, ): RelayClient => { - const serverUrl = options.serverUrl ?? `ws://localhost:${DEFAULT_RELAY_PORT}`; + const serverUrl = options.serverUrl ?? getDefaultWebSocketUrl(); const autoReconnect = options.autoReconnect ?? true; const reconnectIntervalMs = options.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS; diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index fce281cd5..1f3f3e848 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -16,6 +16,7 @@ interface ConnectRelayOptions { port?: number; handler: AgentHandler; token?: string; + secure?: boolean; } interface RelayConnection { @@ -25,11 +26,13 @@ interface RelayConnection { const checkIfRelayServerIsRunning = async ( port: number, token?: string, + secure?: boolean, ): Promise => { try { + const httpProtocol = secure ? "https" : "http"; const healthUrl = token - ? `http://localhost:${port}/health?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : `http://localhost:${port}/health`; + ? `${httpProtocol}://localhost:${port}/health?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : `${httpProtocol}://localhost:${port}/health`; const response = await fetch(healthUrl, { method: "GET", signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), @@ -44,7 +47,7 @@ export const connectRelay = async ( options: ConnectRelayOptions, ): Promise => { const relayPort = options.port ?? DEFAULT_RELAY_PORT; - const { handler, token } = options; + const { handler, token, secure } = options; let relayServer: RelayServer | null = null; let isRelayHost = false; @@ -52,15 +55,21 @@ export const connectRelay = async ( const isRelayServerRunning = await checkIfRelayServerIsRunning( relayPort, token, + secure, ); if (isRelayServerRunning) { - relayServer = await connectToExistingRelay(relayPort, handler, token); + relayServer = await connectToExistingRelay( + relayPort, + handler, + token, + secure, + ); } else { await fkill(`:${relayPort}`, { force: true, silent: true }).catch(() => {}); await sleep(POST_KILL_DELAY_MS); - relayServer = createRelayServer({ port: relayPort, token }); + relayServer = createRelayServer({ port: relayPort, token, secure }); relayServer.registerHandler(handler); try { @@ -75,15 +84,24 @@ export const connectRelay = async ( if (!isAddressInUse) throw error; await sleep(POST_KILL_DELAY_MS); - const isNowRunning = await checkIfRelayServerIsRunning(relayPort, token); + const isNowRunning = await checkIfRelayServerIsRunning( + relayPort, + token, + secure, + ); if (!isNowRunning) throw error; - relayServer = await connectToExistingRelay(relayPort, handler, token); + relayServer = await connectToExistingRelay( + relayPort, + handler, + token, + secure, + ); } } - printStartupMessage(handler.agentId, relayPort); + printStartupMessage(handler.agentId, relayPort, secure); const handleShutdown = async () => { if (isRelayHost) { @@ -114,15 +132,18 @@ const connectToExistingRelay = async ( port: number, handler: AgentHandler, token?: string, + secure?: boolean, ): Promise => { const { WebSocket } = await import("ws"); return new Promise((resolve, reject) => { + const webSocketProtocol = secure ? "wss" : "ws"; const connectionUrl = token - ? `ws://localhost:${port}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : `ws://localhost:${port}`; + ? `${webSocketProtocol}://localhost:${port}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : `${webSocketProtocol}://localhost:${port}`; const socket = new WebSocket(connectionUrl, { headers: { "x-relay-handler": "true" }, + rejectUnauthorized: false, }); socket.on("open", () => { @@ -296,9 +317,16 @@ const connectToExistingRelay = async ( }); }; -const printStartupMessage = (agentId: string, port: number) => { +const printStartupMessage = ( + agentId: string, + port: number, + secure?: boolean, +) => { + const webSocketProtocol = secure ? "wss" : "ws"; console.log( `${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION)} ${pc.dim(`(${agentId})`)}`, ); - console.log(`- Local: ${pc.cyan(`ws://localhost:${port}`)}`); + console.log( + `- Local: ${pc.cyan(`${webSocketProtocol}://localhost:${port}`)}`, + ); }; diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts new file mode 100644 index 000000000..81686c863 --- /dev/null +++ b/packages/relay/src/mkcert.ts @@ -0,0 +1,101 @@ +import { exec as execCallback } from "node:child_process"; +import { createWriteStream, existsSync } from "node:fs"; +import { chmod, mkdir, readFile } from "node:fs/promises"; +import { homedir, platform, arch } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { pipeline } from "node:stream/promises"; + +const exec = promisify(execCallback); + +const DATA_DIR = join(homedir(), ".react-grab"); +const MKCERT_PATH = join( + DATA_DIR, + platform() === "win32" ? "mkcert.exe" : "mkcert", +); +const KEY_PATH = join(DATA_DIR, "localhost-key.pem"); +const CERT_PATH = join(DATA_DIR, "localhost.pem"); + +const MKCERT_ENV = { env: { ...process.env, CAROOT: DATA_DIR } }; + +interface GithubReleaseAsset { + name: string; + browser_download_url: string; +} + +interface GithubReleaseResponse { + tag_name: string; + assets: GithubReleaseAsset[]; +} + +const getPlatformIdentifier = (): string => { + const architecture = arch() === "x64" ? "amd64" : arch(); + return platform() === "win32" + ? `windows-${architecture}.exe` + : `${platform()}-${architecture}`; +}; + +const fetchMkcertDownloadUrl = async (): Promise => { + const response = await fetch( + "https://api.github.com/repos/FiloSottile/mkcert/releases/latest", + ); + if (!response.ok) return undefined; + + const releaseData: GithubReleaseResponse = await response.json(); + const platformIdentifier = getPlatformIdentifier(); + const matchingAsset = releaseData.assets.find((asset) => + asset.name.includes(platformIdentifier), + ); + + return matchingAsset?.browser_download_url; +}; + +const downloadMkcert = async (downloadUrl: string): Promise => { + await mkdir(DATA_DIR, { recursive: true }); + + const response = await fetch(downloadUrl); + if (!response.ok || !response.body) { + throw new Error(`Failed to download mkcert: ${response.statusText}`); + } + + const fileStream = createWriteStream(MKCERT_PATH); + // HACK: Web ReadableStream and Node.js ReadableStream types are incompatible but work at runtime + await pipeline(response.body as unknown as NodeJS.ReadableStream, fileStream); + await chmod(MKCERT_PATH, 0o755); +}; + +const runMkcert = async (args: string): Promise => { + await exec(`"${MKCERT_PATH}" ${args}`, MKCERT_ENV); +}; + +export interface Certificate { + key: Buffer; + cert: Buffer; +} + +export const ensureCertificates = async ( + hosts: string[] = ["localhost", "127.0.0.1"], +): Promise => { + if (!existsSync(MKCERT_PATH)) { + const downloadUrl = await fetchMkcertDownloadUrl(); + if (!downloadUrl) { + throw new Error( + `Unsupported platform: ${platform()} ${arch()}. Cannot download mkcert.`, + ); + } + await downloadMkcert(downloadUrl); + await runMkcert("-install"); + } + + if (!existsSync(KEY_PATH) || !existsSync(CERT_PATH)) { + await runMkcert( + `-key-file "${KEY_PATH}" -cert-file "${CERT_PATH}" ${hosts.join(" ")}`, + ); + } + + const [key, cert] = await Promise.all([ + readFile(KEY_PATH), + readFile(CERT_PATH), + ]); + return { key, cert }; +}; diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 7439ca187..a80c361ac 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -1,5 +1,10 @@ import { WebSocketServer, WebSocket } from "ws"; -import { createServer as createHttpServer } from "node:http"; +import { + createServer as createHttpServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { createServer as createHttpsServer } from "node:https"; import type { AgentHandler, AgentMessage, @@ -10,6 +15,7 @@ import type { AgentContext, } from "./protocol.js"; import { DEFAULT_RELAY_PORT, RELAY_TOKEN_PARAM } from "./protocol.js"; +import { ensureCertificates } from "./mkcert.js"; interface RegisteredHandler { agentId: string; @@ -83,6 +89,7 @@ interface ActiveSession { interface RelayServerOptions { port?: number; token?: string; + secure?: boolean; } export interface RelayServer { @@ -98,6 +105,7 @@ export const createRelayServer = ( ): RelayServer => { const port = options.port ?? DEFAULT_RELAY_PORT; const token = options.token; + const secure = options.secure ?? false; const registeredHandlers = new Map(); const activeSessions = new Map(); @@ -106,7 +114,10 @@ export const createRelayServer = ( const sessionMessageQueues = new Map(); const undoRedoSessionOwners = new Map(); - let httpServer: ReturnType | null = null; + let httpServer: + | ReturnType + | ReturnType + | null = null; let webSocketServer: WebSocketServer | null = null; const broadcastHandlerList = () => { @@ -516,8 +527,10 @@ export const createRelayServer = ( }; const start = async (): Promise => { + const certificate = secure ? await ensureCertificates() : null; + return new Promise((resolve, reject) => { - httpServer = createHttpServer((req, res) => { + const requestHandler = (req: IncomingMessage, res: ServerResponse) => { const requestUrl = new URL(req.url ?? "", `http://localhost:${port}`); if (requestUrl.pathname === "/health") { @@ -540,7 +553,19 @@ export const createRelayServer = ( } res.writeHead(404); res.end(); - }); + }; + + if (secure && certificate) { + httpServer = createHttpsServer( + { + key: certificate.key, + cert: certificate.cert, + }, + requestHandler, + ); + } else { + httpServer = createHttpServer(requestHandler); + } httpServer.on("error", (error) => { reject(error); From a96448b4345b6264d2fbd2a86c70a9c6c2f5eeb3 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 5 Feb 2026 04:56:25 -0800 Subject: [PATCH 02/26] fix: use current hostname instead of hardcoded localhost Browser client now auto-configures the relay URL based on the current window.location.hostname and protocol. This allows the relay to work seamlessly when proxied through tools like localias. Example: if page is at https://myapp.local, relay connects to wss://myapp.local:4722 instead of wss://localhost:4722 --- packages/relay/src/client.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index f8b52bf70..ab0727fff 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -31,9 +31,12 @@ interface RelayClientOptions { } const getDefaultWebSocketUrl = (): string => { - const isSecure = - typeof window !== "undefined" && window.location.protocol === "https:"; - return `${isSecure ? "wss" : "ws"}://localhost:${DEFAULT_RELAY_PORT}`; + if (typeof window === "undefined") { + return `ws://localhost:${DEFAULT_RELAY_PORT}`; + } + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const hostname = window.location.hostname; + return `${protocol}//${hostname}:${DEFAULT_RELAY_PORT}`; }; export const createRelayClient = ( From 058940074875ba365a5c56db9f525b75281d64af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 13:05:10 +0000 Subject: [PATCH 03/26] fix: resolve SSL, mkcert, and code duplication issues - Add SSL certificate bypass to HTTPS health checks to match WebSocket behavior - Separate CA installation tracking from mkcert binary existence check - Clean up partial mkcert downloads on failure - Extract duplicated CLI bootstrapping logic to shared utility --- packages/provider-amp/src/cli.ts | 11 ++--------- packages/provider-claude-code/src/cli.ts | 11 ++--------- packages/provider-codex/src/cli.ts | 11 ++--------- packages/provider-cursor/src/cli.ts | 11 ++--------- packages/provider-droid/src/cli.ts | 11 ++--------- packages/provider-gemini/src/cli.ts | 11 ++--------- packages/provider-opencode/src/cli.ts | 11 ++--------- packages/relay/src/connection.ts | 14 ++++++++++++-- packages/relay/src/mkcert.ts | 24 ++++++++++++++++++++---- packages/utils/src/server.ts | 11 +++++++++++ 10 files changed, 57 insertions(+), 69 deletions(-) diff --git a/packages/provider-amp/src/cli.ts b/packages/provider-amp/src/cli.ts index 00e13f1cf..2b9488f63 100644 --- a/packages/provider-amp/src/cli.ts +++ b/packages/provider-amp/src/cli.ts @@ -1,17 +1,10 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); -const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath, ...userArgs], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(serverPath); diff --git a/packages/provider-claude-code/src/cli.ts b/packages/provider-claude-code/src/cli.ts index 00e13f1cf..2b9488f63 100644 --- a/packages/provider-claude-code/src/cli.ts +++ b/packages/provider-claude-code/src/cli.ts @@ -1,17 +1,10 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); -const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath, ...userArgs], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(serverPath); diff --git a/packages/provider-codex/src/cli.ts b/packages/provider-codex/src/cli.ts index 00e13f1cf..2b9488f63 100644 --- a/packages/provider-codex/src/cli.ts +++ b/packages/provider-codex/src/cli.ts @@ -1,17 +1,10 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); -const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath, ...userArgs], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(serverPath); diff --git a/packages/provider-cursor/src/cli.ts b/packages/provider-cursor/src/cli.ts index 00e13f1cf..2b9488f63 100644 --- a/packages/provider-cursor/src/cli.ts +++ b/packages/provider-cursor/src/cli.ts @@ -1,17 +1,10 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); -const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath, ...userArgs], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(serverPath); diff --git a/packages/provider-droid/src/cli.ts b/packages/provider-droid/src/cli.ts index 00e13f1cf..2b9488f63 100644 --- a/packages/provider-droid/src/cli.ts +++ b/packages/provider-droid/src/cli.ts @@ -1,17 +1,10 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); -const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath, ...userArgs], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(serverPath); diff --git a/packages/provider-gemini/src/cli.ts b/packages/provider-gemini/src/cli.ts index 00e13f1cf..2b9488f63 100644 --- a/packages/provider-gemini/src/cli.ts +++ b/packages/provider-gemini/src/cli.ts @@ -1,17 +1,10 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); -const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath, ...userArgs], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(serverPath); diff --git a/packages/provider-opencode/src/cli.ts b/packages/provider-opencode/src/cli.ts index 00e13f1cf..2b9488f63 100644 --- a/packages/provider-opencode/src/cli.ts +++ b/packages/provider-opencode/src/cli.ts @@ -1,17 +1,10 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; +import { spawnDetachedServer } from "@react-grab/utils/server"; const realScriptPath = realpathSync(process.argv[1]); const scriptDir = dirname(realScriptPath); const serverPath = join(scriptDir, "server.cjs"); -const userArgs = process.argv.slice(2); -const child = spawn(process.execPath, [serverPath, ...userArgs], { - detached: true, - stdio: "inherit", -}); - -child.unref(); -process.exit(0); +spawnDetachedServer(serverPath); diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 1f3f3e848..5cf040bcc 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -33,10 +33,20 @@ const checkIfRelayServerIsRunning = async ( const healthUrl = token ? `${httpProtocol}://localhost:${port}/health?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` : `${httpProtocol}://localhost:${port}/health`; - const response = await fetch(healthUrl, { + + const fetchOptions: RequestInit & { dispatcher?: unknown } = { method: "GET", signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), - }); + }; + + if (secure) { + const { Agent } = await import("undici"); + fetchOptions.dispatcher = new Agent({ + connect: { rejectUnauthorized: false }, + }); + } + + const response = await fetch(healthUrl, fetchOptions); return response.ok; } catch { return false; diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts index 81686c863..a60362341 100644 --- a/packages/relay/src/mkcert.ts +++ b/packages/relay/src/mkcert.ts @@ -1,6 +1,6 @@ import { exec as execCallback } from "node:child_process"; import { createWriteStream, existsSync } from "node:fs"; -import { chmod, mkdir, readFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir, platform, arch } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -59,9 +59,18 @@ const downloadMkcert = async (downloadUrl: string): Promise => { } const fileStream = createWriteStream(MKCERT_PATH); - // HACK: Web ReadableStream and Node.js ReadableStream types are incompatible but work at runtime - await pipeline(response.body as unknown as NodeJS.ReadableStream, fileStream); - await chmod(MKCERT_PATH, 0o755); + try { + // HACK: Web ReadableStream and Node.js ReadableStream types are incompatible but work at runtime + await pipeline( + response.body as unknown as NodeJS.ReadableStream, + fileStream, + ); + await chmod(MKCERT_PATH, 0o755); + } catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(MKCERT_PATH).catch(() => {}); + throw error; + } }; const runMkcert = async (args: string): Promise => { @@ -73,6 +82,8 @@ export interface Certificate { cert: Buffer; } +const CA_INSTALLED_FLAG = join(DATA_DIR, ".ca-installed"); + export const ensureCertificates = async ( hosts: string[] = ["localhost", "127.0.0.1"], ): Promise => { @@ -84,7 +95,12 @@ export const ensureCertificates = async ( ); } await downloadMkcert(downloadUrl); + } + + if (!existsSync(CA_INSTALLED_FLAG)) { await runMkcert("-install"); + await mkdir(DATA_DIR, { recursive: true }); + await writeFile(CA_INSTALLED_FLAG, ""); } if (!existsSync(KEY_PATH) || !existsSync(CERT_PATH)) { diff --git a/packages/utils/src/server.ts b/packages/utils/src/server.ts index 6a0795e19..ac6015e89 100644 --- a/packages/utils/src/server.ts +++ b/packages/utils/src/server.ts @@ -55,3 +55,14 @@ export const formatSpawnError = (error: Error, commandName: string): string => { return error.message; }; + +export const spawnDetachedServer = (serverPath: string): void => { + const { spawn } = require("node:child_process"); + const userArgs = process.argv.slice(2); + const child = spawn(process.execPath, [serverPath, ...userArgs], { + detached: true, + stdio: "inherit", + }); + child.unref(); + process.exit(0); +}; From 8510d3cdde0cafd61fd8286d548393d28297810e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 5 Feb 2026 05:08:30 -0800 Subject: [PATCH 04/26] fix --- packages/relay/src/client.ts | 129 +++++++++++++++++++++---------- packages/relay/src/connection.ts | 64 +++++++++++---- packages/relay/src/server.ts | 18 +++++ 3 files changed, 154 insertions(+), 57 deletions(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index ab0727fff..248b840e2 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -30,15 +30,34 @@ interface RelayClientOptions { token?: string; } +const isSecureContext = (): boolean => + typeof window !== "undefined" && window.location.protocol === "https:"; + const getDefaultWebSocketUrl = (): string => { if (typeof window === "undefined") { return `ws://localhost:${DEFAULT_RELAY_PORT}`; } - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const hostname = window.location.hostname; - return `${protocol}//${hostname}:${DEFAULT_RELAY_PORT}`; + const protocol = isSecureContext() ? "wss:" : "ws:"; + return `${protocol}//${window.location.hostname}:${DEFAULT_RELAY_PORT}`; +}; + +const getHealthCheckUrl = (wsUrl: string, token?: string): string => { + const url = new URL( + wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"), + ); + url.pathname = "/health"; + if (isSecureContext()) { + url.searchParams.set("secure", "true"); + } + if (token) { + url.searchParams.set(RELAY_TOKEN_PARAM, token); + } + return url.toString(); }; +const UPGRADE_RETRY_DELAY_MS = 1000; +const MAX_UPGRADE_RETRIES = 5; + export const createRelayClient = ( options: RelayClientOptions = {}, ): RelayClient => { @@ -86,6 +105,28 @@ export const createRelayClient = ( } catch {} }; + const ensureServerReady = async (): Promise => { + const healthUrl = getHealthCheckUrl(serverUrl, token); + + for (let attempt = 0; attempt < MAX_UPGRADE_RETRIES; attempt++) { + try { + const response = await fetch(healthUrl); + if (!response.ok) continue; + + const data = (await response.json()) as { status: string }; + if (data.status === "upgrading") { + await new Promise((resolve) => + setTimeout(resolve, UPGRADE_RETRY_DELAY_MS), + ); + continue; + } + return; + } catch { + return; + } + } + }; + const connect = (): Promise => { if (webSocketConnection?.readyState === WebSocket.OPEN) { return Promise.resolve(); @@ -97,49 +138,53 @@ export const createRelayClient = ( isIntentionalDisconnect = false; - pendingConnectionPromise = new Promise((resolve, reject) => { - pendingConnectionReject = reject; - const connectionUrl = token - ? `${serverUrl}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : serverUrl; - webSocketConnection = new WebSocket(connectionUrl); - - webSocketConnection.onopen = () => { - pendingConnectionPromise = null; - pendingConnectionReject = null; - isConnectedState = true; - for (const callback of connectionChangeCallbacks) { - callback(true); - } - resolve(); - }; + pendingConnectionPromise = (async () => { + await ensureServerReady(); - webSocketConnection.onmessage = handleMessage; + return new Promise((resolve, reject) => { + pendingConnectionReject = reject; + const connectionUrl = token + ? `${serverUrl}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : serverUrl; + webSocketConnection = new WebSocket(connectionUrl); - webSocketConnection.onclose = () => { - if (pendingConnectionReject) { - pendingConnectionReject(new Error("WebSocket connection closed")); + webSocketConnection.onopen = () => { + pendingConnectionPromise = null; pendingConnectionReject = null; - } - pendingConnectionPromise = null; - isConnectedState = false; - availableHandlers = []; - for (const callback of handlersChangeCallbacks) { - callback(availableHandlers); - } - for (const callback of connectionChangeCallbacks) { - callback(false); - } - scheduleReconnect(); - }; + isConnectedState = true; + for (const callback of connectionChangeCallbacks) { + callback(true); + } + resolve(); + }; - webSocketConnection.onerror = () => { - pendingConnectionPromise = null; - pendingConnectionReject = null; - isConnectedState = false; - reject(new Error("WebSocket connection failed")); - }; - }); + webSocketConnection.onmessage = handleMessage; + + webSocketConnection.onclose = () => { + if (pendingConnectionReject) { + pendingConnectionReject(new Error("WebSocket connection closed")); + pendingConnectionReject = null; + } + pendingConnectionPromise = null; + isConnectedState = false; + availableHandlers = []; + for (const callback of handlersChangeCallbacks) { + callback(availableHandlers); + } + for (const callback of connectionChangeCallbacks) { + callback(false); + } + scheduleReconnect(); + }; + + webSocketConnection.onerror = () => { + pendingConnectionPromise = null; + pendingConnectionReject = null; + isConnectedState = false; + reject(new Error("WebSocket connection failed")); + }; + }); + })(); return pendingConnectionPromise; }; diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 5cf040bcc..d4b9c0799 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -68,18 +68,39 @@ export const connectRelay = async ( secure, ); - if (isRelayServerRunning) { - relayServer = await connectToExistingRelay( - relayPort, - handler, - token, - secure, - ); - } else { - await fkill(`:${relayPort}`, { force: true, silent: true }).catch(() => {}); - await sleep(POST_KILL_DELAY_MS); + let isSecureMode = secure ?? false; + + const startServer = async (useSecure: boolean): Promise => { + const onSecureUpgradeRequested = async () => { + if (isSecureMode || !isRelayHost) return; + + console.log( + pc.yellow( + "Secure connection requested by browser, upgrading to HTTPS...", + ), + ); + + await relayServer?.stop(); + isSecureMode = true; + + relayServer = createRelayServer({ + port: relayPort, + token, + secure: true, + onSecureUpgradeRequested, + }); + relayServer.registerHandler(handler); + await relayServer.start(); + + printStartupMessage(handler.agentId, relayPort, true); + }; - relayServer = createRelayServer({ port: relayPort, token, secure }); + relayServer = createRelayServer({ + port: relayPort, + token, + secure: useSecure, + onSecureUpgradeRequested, + }); relayServer.registerHandler(handler); try { @@ -97,7 +118,7 @@ export const connectRelay = async ( const isNowRunning = await checkIfRelayServerIsRunning( relayPort, token, - secure, + useSecure, ); if (!isNowRunning) throw error; @@ -106,12 +127,25 @@ export const connectRelay = async ( relayPort, handler, token, - secure, + useSecure, ); } + }; + + if (isRelayServerRunning) { + relayServer = await connectToExistingRelay( + relayPort, + handler, + token, + secure, + ); + } else { + await fkill(`:${relayPort}`, { force: true, silent: true }).catch(() => {}); + await sleep(POST_KILL_DELAY_MS); + await startServer(isSecureMode); } - printStartupMessage(handler.agentId, relayPort, secure); + printStartupMessage(handler.agentId, relayPort, isSecureMode); const handleShutdown = async () => { if (isRelayHost) { @@ -330,7 +364,7 @@ const connectToExistingRelay = async ( const printStartupMessage = ( agentId: string, port: number, - secure?: boolean, + secure: boolean, ) => { const webSocketProtocol = secure ? "wss" : "ws"; console.log( diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index a80c361ac..0425b03a7 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -90,6 +90,7 @@ interface RelayServerOptions { port?: number; token?: string; secure?: boolean; + onSecureUpgradeRequested?: () => void; } export interface RelayServer { @@ -106,6 +107,7 @@ export const createRelayServer = ( const port = options.port ?? DEFAULT_RELAY_PORT; const token = options.token; const secure = options.secure ?? false; + const onSecureUpgradeRequested = options.onSecureUpgradeRequested; const registeredHandlers = new Map(); const activeSessions = new Map(); @@ -542,10 +544,26 @@ export const createRelayServer = ( return; } } + + const clientNeedsSecure = + requestUrl.searchParams.get("secure") === "true"; + if (clientNeedsSecure && !secure && onSecureUpgradeRequested) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: "upgrading", + handlers: getRegisteredHandlerIds(), + }), + ); + onSecureUpgradeRequested(); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ status: "ok", + secure, handlers: getRegisteredHandlerIds(), }), ); From 5f1aa7b917e3eb3bcde7b765dbaa898ad93e3010 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 13:08:17 +0000 Subject: [PATCH 05/26] fix: add certHostnames option to support custom local domains Allow users to specify custom hostnames for SSL certificates via the certHostnames option in RelayServerOptions. This fixes certificate hostname mismatch when accessing the app via custom local domains like myapp.local. The option is passed to ensureCertificates() to generate certificates that include the specified hostnames in their Subject Alternative Names. --- packages/relay/src/server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 0425b03a7..4ee2c470c 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -91,6 +91,7 @@ interface RelayServerOptions { token?: string; secure?: boolean; onSecureUpgradeRequested?: () => void; + certHostnames?: string[]; } export interface RelayServer { @@ -108,6 +109,7 @@ export const createRelayServer = ( const token = options.token; const secure = options.secure ?? false; const onSecureUpgradeRequested = options.onSecureUpgradeRequested; + const certHostnames = options.certHostnames; const registeredHandlers = new Map(); const activeSessions = new Map(); @@ -529,7 +531,7 @@ export const createRelayServer = ( }; const start = async (): Promise => { - const certificate = secure ? await ensureCertificates() : null; + const certificate = secure ? await ensureCertificates(certHostnames) : null; return new Promise((resolve, reject) => { const requestHandler = (req: IncomingMessage, res: ServerResponse) => { From b3ebab86d9b91012aa32fe0b4c6dbc32ee71df72 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 5 Feb 2026 05:18:56 -0800 Subject: [PATCH 06/26] fix: use native https module instead of undici for health checks Removes undici dependency by using Node.js native https module with rejectUnauthorized: false to handle self-signed certificates during health checks. --- packages/relay/src/connection.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index d4b9c0799..38ecd929e 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -34,19 +34,26 @@ const checkIfRelayServerIsRunning = async ( ? `${httpProtocol}://localhost:${port}/health?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` : `${httpProtocol}://localhost:${port}/health`; - const fetchOptions: RequestInit & { dispatcher?: unknown } = { - method: "GET", - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), - }; - if (secure) { - const { Agent } = await import("undici"); - fetchOptions.dispatcher = new Agent({ - connect: { rejectUnauthorized: false }, + const https = await import("node:https"); + return new Promise((resolve) => { + const request = https.get( + healthUrl, + { rejectUnauthorized: false, timeout: HEALTH_CHECK_TIMEOUT_MS }, + (response) => resolve(response.statusCode === 200), + ); + request.on("error", () => resolve(false)); + request.on("timeout", () => { + request.destroy(); + resolve(false); + }); }); } - const response = await fetch(healthUrl, fetchOptions); + const response = await fetch(healthUrl, { + method: "GET", + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), + }); return response.ok; } catch { return false; From e384018926f50ae5dee126100c1d8e22750b8584 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 13:21:24 +0000 Subject: [PATCH 07/26] Fix WSS relay bugs: cert hostnames, race condition, health check protocol, and static imports - Add certHostnames option to ConnectRelayOptions and pass it to createRelayServer calls to support custom SSL hostnames (e.g., localias setups) - Fix race condition in secure upgrade by setting isSecureMode flag before async operations to prevent concurrent upgrades - Fix auto-upgrade by using HTTP for health checks instead of HTTPS to allow insecure server to receive secure upgrade request - Replace dynamic require with static ES import for node:child_process in spawnDetachedServer --- packages/relay/src/client.ts | 4 +--- packages/relay/src/connection.ts | 7 +++++-- packages/utils/src/server.ts | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index 248b840e2..a1795d9aa 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -42,9 +42,7 @@ const getDefaultWebSocketUrl = (): string => { }; const getHealthCheckUrl = (wsUrl: string, token?: string): string => { - const url = new URL( - wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"), - ); + const url = new URL(wsUrl.replace(/^wss?:/, "http:")); url.pathname = "/health"; if (isSecureContext()) { url.searchParams.set("secure", "true"); diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 38ecd929e..35ff10272 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -17,6 +17,7 @@ interface ConnectRelayOptions { handler: AgentHandler; token?: string; secure?: boolean; + certHostnames?: string[]; } interface RelayConnection { @@ -64,7 +65,7 @@ export const connectRelay = async ( options: ConnectRelayOptions, ): Promise => { const relayPort = options.port ?? DEFAULT_RELAY_PORT; - const { handler, token, secure } = options; + const { handler, token, secure, certHostnames } = options; let relayServer: RelayServer | null = null; let isRelayHost = false; @@ -80,6 +81,7 @@ export const connectRelay = async ( const startServer = async (useSecure: boolean): Promise => { const onSecureUpgradeRequested = async () => { if (isSecureMode || !isRelayHost) return; + isSecureMode = true; console.log( pc.yellow( @@ -88,13 +90,13 @@ export const connectRelay = async ( ); await relayServer?.stop(); - isSecureMode = true; relayServer = createRelayServer({ port: relayPort, token, secure: true, onSecureUpgradeRequested, + certHostnames, }); relayServer.registerHandler(handler); await relayServer.start(); @@ -107,6 +109,7 @@ export const connectRelay = async ( token, secure: useSecure, onSecureUpgradeRequested, + certHostnames, }); relayServer.registerHandler(handler); diff --git a/packages/utils/src/server.ts b/packages/utils/src/server.ts index ac6015e89..8c0eca9b1 100644 --- a/packages/utils/src/server.ts +++ b/packages/utils/src/server.ts @@ -1,3 +1,5 @@ +import { spawn } from "node:child_process"; + export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -57,7 +59,6 @@ export const formatSpawnError = (error: Error, commandName: string): string => { }; export const spawnDetachedServer = (serverPath: string): void => { - const { spawn } = require("node:child_process"); const userArgs = process.argv.slice(2); const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, From f8197ae6c0a2a38a282c5229bf638802c1e02e07 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 5 Feb 2026 05:24:18 -0800 Subject: [PATCH 08/26] fix: add @types/node to utils package and use proper import Fixes CI build failure caused by missing Node.js type definitions in the utils package DTS generation. --- packages/utils/package.json | 1 + pnpm-lock.yaml | 79 +++++++++++++++++++------------------ 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/packages/utils/package.json b/packages/utils/package.json index 1470d6a1d..c45ca7a61 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -22,6 +22,7 @@ "build": "rm -rf dist && NODE_ENV=production tsup" }, "devDependencies": { + "@types/node": "^25.2.0", "tsup": "^8.4.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805ef9c48..fd37d9e59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: devDependencies: '@changesets/cli': specifier: ^2.27.10 - version: 2.29.7(@types/node@24.10.2) + version: 2.29.7(@types/node@25.2.0) oxfmt: specifier: ^0.27.0 version: 0.27.0 @@ -38,7 +38,7 @@ importers: devDependencies: vite: specifier: ^6.0.7 - version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) packages/cli: dependencies: @@ -66,7 +66,7 @@ importers: version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) packages/design-system: dependencies: @@ -113,7 +113,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: 4.0.0-beta.8 - version: 4.0.0-beta.8(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.0.0-beta.8(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) '@types/react': specifier: ^19.0.4 version: 19.2.7 @@ -122,7 +122,7 @@ importers: version: 19.2.2(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) tailwindcss: specifier: 4.0.0-beta.8 version: 4.0.0-beta.8 @@ -131,7 +131,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.2 - version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) packages/grab: dependencies: @@ -607,6 +607,9 @@ importers: packages/utils: devDependencies: + '@types/node': + specifier: ^25.2.0 + version: 25.2.0 tsup: specifier: ^8.4.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) @@ -637,7 +640,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: 4.0.0-beta.8 - version: 4.0.0-beta.8(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.0.0-beta.8(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) '@types/react': specifier: ^19.0.4 version: 19.2.2 @@ -646,7 +649,7 @@ importers: version: 19.2.2(@types/react@19.2.2) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) tailwindcss: specifier: 4.0.0-beta.8 version: 4.0.0-beta.8 @@ -655,7 +658,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.2 - version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) packages/web-extension: dependencies: @@ -692,16 +695,16 @@ importers: version: 0.12.4 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) typescript: specifier: ^5.7.3 version: 5.9.3 vite: specifier: ^6.0.2 - version: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) vite-plugin-web-extension: specifier: ^4.1.6 - version: 4.5.0(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6) + version: 4.5.0(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6) packages/website: dependencies: @@ -3467,8 +3470,8 @@ packages: '@types/node@22.19.2': resolution: {integrity: sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==} - '@types/node@24.10.2': - resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + '@types/node@25.2.0': + resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} @@ -7664,7 +7667,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.7(@types/node@24.10.2)': + '@changesets/cli@2.29.7(@types/node@25.2.0)': dependencies: '@changesets/apply-release-plan': 7.0.13 '@changesets/assemble-release-plan': 6.0.9 @@ -7680,7 +7683,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.2(@types/node@24.10.2) + '@inquirer/external-editor': 1.0.2(@types/node@25.2.0) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -8364,12 +8367,12 @@ snapshots: '@img/sharp-win32-x64@0.34.4': optional: true - '@inquirer/external-editor@1.0.2(@types/node@24.10.2)': + '@inquirer/external-editor@1.0.2(@types/node@25.2.0)': dependencies: chardet: 2.1.0 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 24.10.2 + '@types/node': 25.2.0 '@isaacs/cliui@8.0.2': dependencies: @@ -9518,13 +9521,13 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.15 - '@tailwindcss/vite@4.0.0-beta.8(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + '@tailwindcss/vite@4.0.0-beta.8(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.0.0-beta.8 '@tailwindcss/oxide': 4.0.0-beta.8 lightningcss: 1.30.2 tailwindcss: 4.0.0-beta.8 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) '@tanstack/react-table@8.21.3(react-dom@19.0.1(react@19.0.1))(react@19.0.1)': dependencies: @@ -9630,10 +9633,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.10.2': + '@types/node@25.2.0': dependencies: undici-types: 7.16.0 - optional: true '@types/prompts@2.4.9': dependencies: @@ -9845,7 +9847,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -9853,7 +9855,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -9865,13 +9867,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -13511,8 +13513,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: - optional: true + undici-types@7.16.0: {} unicorn-magic@0.3.0: {} @@ -13680,13 +13681,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): + vite-node@3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -13701,7 +13702,7 @@ snapshots: - tsx - yaml - vite-plugin-web-extension@4.5.0(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6): + vite-plugin-web-extension@4.5.0(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6): dependencies: ajv: 8.17.1 async-lock: 1.4.1 @@ -13711,7 +13712,7 @@ snapshots: lodash.uniq: 4.5.0 lodash.uniqby: 4.7.0 md5: 2.3.0 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) web-ext-option-types: 8.3.1 web-ext-run: 0.2.4 webextension-polyfill: 0.10.0 @@ -13729,7 +13730,7 @@ snapshots: - terser - tsx - vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): + vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) @@ -13738,7 +13739,7 @@ snapshots: rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.2 + '@types/node': 25.2.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -13746,11 +13747,11 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vitest@3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): + vitest@3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -13768,11 +13769,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.1(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.10.2 + '@types/node': 25.2.0 transitivePeerDependencies: - jiti - less From f6029b030bda2d16b656b073064522fc0fca36a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 13:35:06 +0000 Subject: [PATCH 09/26] fix: resolve security and logic bugs in WSS relay implementation - Fix HTTP/HTTPS protocol mapping in health check URL generation - Add hostname validation to prevent shell injection in mkcert - Add error handling for async HTTPS upgrade failures - Refactor spawnDetachedServer to eliminate path computation duplication --- packages/provider-amp/src/cli.ts | 8 +------ packages/provider-claude-code/src/cli.ts | 8 +------ packages/provider-codex/src/cli.ts | 8 +------ packages/provider-cursor/src/cli.ts | 8 +------ packages/provider-droid/src/cli.ts | 8 +------ packages/provider-gemini/src/cli.ts | 8 +------ packages/provider-opencode/src/cli.ts | 8 +------ packages/relay/src/client.ts | 3 ++- packages/relay/src/connection.ts | 30 +++++++++++++++--------- packages/relay/src/mkcert.ts | 8 +++++++ packages/relay/src/server.ts | 2 +- packages/utils/src/server.ts | 8 ++++++- 12 files changed, 44 insertions(+), 63 deletions(-) diff --git a/packages/provider-amp/src/cli.ts b/packages/provider-amp/src/cli.ts index 2b9488f63..646746fc9 100644 --- a/packages/provider-amp/src/cli.ts +++ b/packages/provider-amp/src/cli.ts @@ -1,10 +1,4 @@ #!/usr/bin/env node -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -spawnDetachedServer(serverPath); +spawnDetachedServer(); diff --git a/packages/provider-claude-code/src/cli.ts b/packages/provider-claude-code/src/cli.ts index 2b9488f63..646746fc9 100644 --- a/packages/provider-claude-code/src/cli.ts +++ b/packages/provider-claude-code/src/cli.ts @@ -1,10 +1,4 @@ #!/usr/bin/env node -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -spawnDetachedServer(serverPath); +spawnDetachedServer(); diff --git a/packages/provider-codex/src/cli.ts b/packages/provider-codex/src/cli.ts index 2b9488f63..646746fc9 100644 --- a/packages/provider-codex/src/cli.ts +++ b/packages/provider-codex/src/cli.ts @@ -1,10 +1,4 @@ #!/usr/bin/env node -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -spawnDetachedServer(serverPath); +spawnDetachedServer(); diff --git a/packages/provider-cursor/src/cli.ts b/packages/provider-cursor/src/cli.ts index 2b9488f63..646746fc9 100644 --- a/packages/provider-cursor/src/cli.ts +++ b/packages/provider-cursor/src/cli.ts @@ -1,10 +1,4 @@ #!/usr/bin/env node -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -spawnDetachedServer(serverPath); +spawnDetachedServer(); diff --git a/packages/provider-droid/src/cli.ts b/packages/provider-droid/src/cli.ts index 2b9488f63..646746fc9 100644 --- a/packages/provider-droid/src/cli.ts +++ b/packages/provider-droid/src/cli.ts @@ -1,10 +1,4 @@ #!/usr/bin/env node -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -spawnDetachedServer(serverPath); +spawnDetachedServer(); diff --git a/packages/provider-gemini/src/cli.ts b/packages/provider-gemini/src/cli.ts index 2b9488f63..646746fc9 100644 --- a/packages/provider-gemini/src/cli.ts +++ b/packages/provider-gemini/src/cli.ts @@ -1,10 +1,4 @@ #!/usr/bin/env node -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -spawnDetachedServer(serverPath); +spawnDetachedServer(); diff --git a/packages/provider-opencode/src/cli.ts b/packages/provider-opencode/src/cli.ts index 2b9488f63..646746fc9 100644 --- a/packages/provider-opencode/src/cli.ts +++ b/packages/provider-opencode/src/cli.ts @@ -1,10 +1,4 @@ #!/usr/bin/env node -import { realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; import { spawnDetachedServer } from "@react-grab/utils/server"; -const realScriptPath = realpathSync(process.argv[1]); -const scriptDir = dirname(realScriptPath); -const serverPath = join(scriptDir, "server.cjs"); - -spawnDetachedServer(serverPath); +spawnDetachedServer(); diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index a1795d9aa..f4369d7df 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -42,7 +42,8 @@ const getDefaultWebSocketUrl = (): string => { }; const getHealthCheckUrl = (wsUrl: string, token?: string): string => { - const url = new URL(wsUrl.replace(/^wss?:/, "http:")); + const httpProtocol = wsUrl.startsWith("wss:") ? "https:" : "http:"; + const url = new URL(wsUrl.replace(/^wss?:/, httpProtocol)); url.pathname = "/health"; if (isSecureContext()) { url.searchParams.set("secure", "true"); diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 35ff10272..ada246854 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -91,17 +91,25 @@ export const connectRelay = async ( await relayServer?.stop(); - relayServer = createRelayServer({ - port: relayPort, - token, - secure: true, - onSecureUpgradeRequested, - certHostnames, - }); - relayServer.registerHandler(handler); - await relayServer.start(); - - printStartupMessage(handler.agentId, relayPort, true); + try { + relayServer = createRelayServer({ + port: relayPort, + token, + secure: true, + onSecureUpgradeRequested, + certHostnames, + }); + relayServer.registerHandler(handler); + await relayServer.start(); + + printStartupMessage(handler.agentId, relayPort, true); + } catch (error) { + console.error( + pc.red("Failed to upgrade to HTTPS:"), + error instanceof Error ? error.message : error, + ); + process.exit(1); + } }; relayServer = createRelayServer({ diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts index a60362341..3ff7e63c0 100644 --- a/packages/relay/src/mkcert.ts +++ b/packages/relay/src/mkcert.ts @@ -84,9 +84,17 @@ export interface Certificate { const CA_INSTALLED_FLAG = join(DATA_DIR, ".ca-installed"); +const validateHostname = (hostname: string): void => { + if (!/^[a-zA-Z0-9.-]+$/.test(hostname)) { + throw new Error(`Invalid hostname: ${hostname}`); + } +}; + export const ensureCertificates = async ( hosts: string[] = ["localhost", "127.0.0.1"], ): Promise => { + hosts.forEach(validateHostname); + if (!existsSync(MKCERT_PATH)) { const downloadUrl = await fetchMkcertDownloadUrl(); if (!downloadUrl) { diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 4ee2c470c..a8299a723 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -557,7 +557,7 @@ export const createRelayServer = ( handlers: getRegisteredHandlerIds(), }), ); - onSecureUpgradeRequested(); + onSecureUpgradeRequested().catch(() => {}); return; } diff --git a/packages/utils/src/server.ts b/packages/utils/src/server.ts index 8c0eca9b1..c3cf0d2fd 100644 --- a/packages/utils/src/server.ts +++ b/packages/utils/src/server.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -58,7 +60,11 @@ export const formatSpawnError = (error: Error, commandName: string): string => { return error.message; }; -export const spawnDetachedServer = (serverPath: string): void => { +export const spawnDetachedServer = (serverFilename = "server.cjs"): void => { + const realScriptPath = realpathSync(process.argv[1]); + const scriptDir = dirname(realScriptPath); + const serverPath = join(scriptDir, serverFilename); + const userArgs = process.argv.slice(2); const child = spawn(process.execPath, [serverPath, ...userArgs], { detached: true, From fa6ee95612248ec3364efb40d857e589b29e518d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 13:48:35 +0000 Subject: [PATCH 10/26] Fix certificate hostname tracking and onSecureUpgradeRequested type - Add HOSTS_METADATA_PATH to track which hostnames were used for cert generation - Regenerate certificates when requested hostnames differ from stored ones - Fix onSecureUpgradeRequested callback type from () => void to () => Promise --- packages/relay/src/mkcert.ts | 18 +++++++++++++++++- packages/relay/src/server.ts | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts index 3ff7e63c0..fde9670f4 100644 --- a/packages/relay/src/mkcert.ts +++ b/packages/relay/src/mkcert.ts @@ -15,6 +15,7 @@ const MKCERT_PATH = join( ); const KEY_PATH = join(DATA_DIR, "localhost-key.pem"); const CERT_PATH = join(DATA_DIR, "localhost.pem"); +const HOSTS_METADATA_PATH = join(DATA_DIR, "cert-hosts.json"); const MKCERT_ENV = { env: { ...process.env, CAROOT: DATA_DIR } }; @@ -111,10 +112,25 @@ export const ensureCertificates = async ( await writeFile(CA_INSTALLED_FLAG, ""); } - if (!existsSync(KEY_PATH) || !existsSync(CERT_PATH)) { + let shouldRegenerateCerts = !existsSync(KEY_PATH) || !existsSync(CERT_PATH); + + if (!shouldRegenerateCerts && existsSync(HOSTS_METADATA_PATH)) { + const storedHostsData = await readFile(HOSTS_METADATA_PATH, "utf-8"); + const storedHosts = JSON.parse(storedHostsData) as string[]; + const sortedStoredHosts = [...storedHosts].sort(); + const sortedRequestedHosts = [...hosts].sort(); + shouldRegenerateCerts = + JSON.stringify(sortedStoredHosts) !== + JSON.stringify(sortedRequestedHosts); + } else if (!shouldRegenerateCerts) { + shouldRegenerateCerts = true; + } + + if (shouldRegenerateCerts) { await runMkcert( `-key-file "${KEY_PATH}" -cert-file "${CERT_PATH}" ${hosts.join(" ")}`, ); + await writeFile(HOSTS_METADATA_PATH, JSON.stringify(hosts)); } const [key, cert] = await Promise.all([ diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index a8299a723..26e6d2c59 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -90,7 +90,7 @@ interface RelayServerOptions { port?: number; token?: string; secure?: boolean; - onSecureUpgradeRequested?: () => void; + onSecureUpgradeRequested?: () => Promise; certHostnames?: string[]; } From 54b867ce9a564c15e7bcd229164c1a5b881bf3f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 14:06:06 +0000 Subject: [PATCH 11/26] Fix unnecessary dynamic import for unlink in mkcert.ts --- packages/relay/src/mkcert.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts index fde9670f4..cb7a959ff 100644 --- a/packages/relay/src/mkcert.ts +++ b/packages/relay/src/mkcert.ts @@ -1,6 +1,6 @@ import { exec as execCallback } from "node:child_process"; import { createWriteStream, existsSync } from "node:fs"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { homedir, platform, arch } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -68,7 +68,6 @@ const downloadMkcert = async (downloadUrl: string): Promise => { ); await chmod(MKCERT_PATH, 0o755); } catch (error) { - const { unlink } = await import("node:fs/promises"); await unlink(MKCERT_PATH).catch(() => {}); throw error; } From b772d013b10e4874b2317d8c958287431133471f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 00:41:17 +0000 Subject: [PATCH 12/26] fix: handle corrupted cert-hosts.json with try-catch around JSON.parse If cert-hosts.json becomes corrupted or contains invalid JSON (due to interrupted writes, disk errors, or manual editing), the JSON.parse call at line 118 would throw an unhandled exception, preventing secure mode from working until the user manually deleted the file. Wrap the JSON.parse and file read in a try-catch block. On any error, set shouldRegenerateCerts to true so that certificates are regenerated and a valid metadata file is written, recovering automatically. Co-authored-by: Aiden Bai --- packages/relay/src/mkcert.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts index cb7a959ff..a5abc76b2 100644 --- a/packages/relay/src/mkcert.ts +++ b/packages/relay/src/mkcert.ts @@ -114,13 +114,17 @@ export const ensureCertificates = async ( let shouldRegenerateCerts = !existsSync(KEY_PATH) || !existsSync(CERT_PATH); if (!shouldRegenerateCerts && existsSync(HOSTS_METADATA_PATH)) { - const storedHostsData = await readFile(HOSTS_METADATA_PATH, "utf-8"); - const storedHosts = JSON.parse(storedHostsData) as string[]; - const sortedStoredHosts = [...storedHosts].sort(); - const sortedRequestedHosts = [...hosts].sort(); - shouldRegenerateCerts = - JSON.stringify(sortedStoredHosts) !== - JSON.stringify(sortedRequestedHosts); + try { + const storedHostsData = await readFile(HOSTS_METADATA_PATH, "utf-8"); + const storedHosts = JSON.parse(storedHostsData) as string[]; + const sortedStoredHosts = [...storedHosts].sort(); + const sortedRequestedHosts = [...hosts].sort(); + shouldRegenerateCerts = + JSON.stringify(sortedStoredHosts) !== + JSON.stringify(sortedRequestedHosts); + } catch { + shouldRegenerateCerts = true; + } } else if (!shouldRegenerateCerts) { shouldRegenerateCerts = true; } From df38df2c86b4bef90e5655c6631edf0d49155d66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 00:43:15 +0000 Subject: [PATCH 13/26] fix: prevent hostname flag injection in mkcert execution - Reject hostnames starting with '-' in validateHostname to prevent values like '-help' or '-install' from passing validation - Add '--' end-of-flags separator before hosts in the runMkcert call as defense-in-depth against flag injection Co-authored-by: Aiden Bai --- packages/relay/src/mkcert.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts index a5abc76b2..3d3833a75 100644 --- a/packages/relay/src/mkcert.ts +++ b/packages/relay/src/mkcert.ts @@ -85,6 +85,9 @@ export interface Certificate { const CA_INSTALLED_FLAG = join(DATA_DIR, ".ca-installed"); const validateHostname = (hostname: string): void => { + if (hostname.startsWith("-")) { + throw new Error(`Invalid hostname (leading hyphen): ${hostname}`); + } if (!/^[a-zA-Z0-9.-]+$/.test(hostname)) { throw new Error(`Invalid hostname: ${hostname}`); } @@ -131,7 +134,7 @@ export const ensureCertificates = async ( if (shouldRegenerateCerts) { await runMkcert( - `-key-file "${KEY_PATH}" -cert-file "${CERT_PATH}" ${hosts.join(" ")}`, + `-key-file "${KEY_PATH}" -cert-file "${CERT_PATH}" -- ${hosts.join(" ")}`, ); await writeFile(HOSTS_METADATA_PATH, JSON.stringify(hosts)); } From 94006d76c202f1e5baee3e22ef6794ef9fe292b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 00:45:14 +0000 Subject: [PATCH 14/26] fix: properly await httpServer.close() and webSocketServer.close() in stop() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop() function was declared async but did not await the completion of httpServer.close() or webSocketServer.close(). In Node.js, these methods are callback-based and asynchronous — the port is not released until the callback fires. During secure upgrade in connection.ts, 'await relayServer?.stop()' would return before the port was actually freed, then the new HTTPS server would try to bind to the same port, causing an intermittent EADDRINUSE race condition. Wrap both close() calls in promises so stop() only resolves after the WebSocket server and HTTP server have fully shut down and released their ports. Co-authored-by: Aiden Bai --- packages/relay/src/server.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 26e6d2c59..ec44a2fa7 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -702,8 +702,30 @@ export const createRelayServer = ( } handlerSockets.clear(); - webSocketServer?.close(); - httpServer?.close(); + const webSocketClosePromise = new Promise((resolve) => { + if (webSocketServer) { + webSocketServer.close(() => resolve()); + } else { + resolve(); + } + }); + + const httpServerClosePromise = new Promise((resolve, reject) => { + if (httpServer) { + httpServer.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + } else { + resolve(); + } + }); + + await webSocketClosePromise; + await httpServerClosePromise; }; const registerHandler = (handler: AgentHandler) => { From 9b8b3710a2ca3640b9992c2bb3fa51156a7b4613 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Feb 2026 00:45:23 +0000 Subject: [PATCH 15/26] fix: add retry delay when response.ok is false in ensureServerReady When the health check returns an error status (401, 500, etc.), the loop was immediately continuing to the next attempt without any delay. This caused up to 5 rapid-fire requests in quick succession. The delay was only being applied for the 'upgrading' status case. Now applies the same UPGRADE_RETRY_DELAY_MS delay before retrying when the response is not ok, consistent with the upgrading case. Co-authored-by: Aiden Bai --- packages/relay/src/client.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index f4369d7df..ff38088f4 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -110,7 +110,12 @@ export const createRelayClient = ( for (let attempt = 0; attempt < MAX_UPGRADE_RETRIES; attempt++) { try { const response = await fetch(healthUrl); - if (!response.ok) continue; + if (!response.ok) { + await new Promise((resolve) => + setTimeout(resolve, UPGRADE_RETRY_DELAY_MS), + ); + continue; + } const data = (await response.json()) as { status: string }; if (data.status === "upgrading") { From ab76689d02db4063618f6b43689feb5bc40002ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Feb 2026 23:16:52 +0000 Subject: [PATCH 16/26] fix: retry on network errors during server upgrade The ensureServerReady function now retries when fetch throws network errors (connection refused, timeout) instead of immediately returning. This allows the client to wait for the server to complete its HTTPS upgrade restart cycle before attempting the WebSocket connection. --- packages/relay/src/client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index ff38088f4..477d1b16e 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -126,7 +126,10 @@ export const createRelayClient = ( } return; } catch { - return; + await new Promise((resolve) => + setTimeout(resolve, UPGRADE_RETRY_DELAY_MS), + ); + continue; } } }; From 65cff04f0d62d278011528c02793b48a91a22910 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 7 Feb 2026 15:31:41 -0800 Subject: [PATCH 17/26] fix: implement retry delay for connection upgrades and enhance error handling Added a retry delay for connection upgrades when the response is not ok, ensuring consistent behavior with the existing upgrade retry logic. Improved error handling during secure connection upgrades to prevent unhandled exceptions and ensure graceful degradation in case of failures. Co-authored-by: Aiden Bai --- .../react-grab/e2e/freeze-animations.spec.ts | 9 ++++----- .../react-grab/src/components/toolbar/index.tsx | 14 +++++++++++--- packages/relay/src/client.ts | 16 +++++++++------- packages/relay/src/connection.ts | 3 +++ packages/relay/src/mkcert.ts | 1 - packages/relay/src/protocol.ts | 2 ++ packages/relay/src/server.ts | 7 ++++++- 7 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/react-grab/e2e/freeze-animations.spec.ts b/packages/react-grab/e2e/freeze-animations.spec.ts index 4e879d79b..2ee8a43ec 100644 --- a/packages/react-grab/e2e/freeze-animations.spec.ts +++ b/packages/react-grab/e2e/freeze-animations.spec.ts @@ -9,8 +9,9 @@ test.describe("Freeze Animations", () => { }) => { const getPageAnimationStates = async () => { return reactGrab.page.evaluate((attrName) => { - return document.getAnimations().reduce( - (states, animation) => { + return document + .getAnimations() + .reduce((states, animation) => { if (animation.effect instanceof KeyframeEffect) { const target = animation.effect.target; if (target instanceof Element) { @@ -25,9 +26,7 @@ test.describe("Freeze Animations", () => { } states.push(animation.playState); return states; - }, - [], - ); + }, []); }, ATTRIBUTE_NAME); }; diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index ad2650ca5..a6af80018 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -456,7 +456,9 @@ export const Toolbar: Component = (props) => { props.onToggleEnabled?.(); if (expandableWidth > 0) { - const widthChange = isCurrentlyEnabled ? -expandableWidth : expandableWidth; + const widthChange = isCurrentlyEnabled + ? -expandableWidth + : expandableWidth; expandedDimensions = { width: expandedDimensions.width + widthChange, height: expandedDimensions.height, @@ -465,9 +467,15 @@ export const Toolbar: Component = (props) => { if (shouldCompensatePosition) { const viewport = getVisualViewport(); - const positionOffset = isCurrentlyEnabled ? expandableWidth : -expandableWidth; + const positionOffset = isCurrentlyEnabled + ? expandableWidth + : -expandableWidth; const clampMin = viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX; - const clampMax = viewport.offsetLeft + viewport.width - expandedDimensions.width - TOOLBAR_SNAP_MARGIN_PX; + const clampMax = + viewport.offsetLeft + + viewport.width - + expandedDimensions.width - + TOOLBAR_SNAP_MARGIN_PX; const compensatedX = clampToViewport( preTogglePosition.x + positionOffset, clampMin, diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index 477d1b16e..d957116b1 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -7,6 +7,8 @@ import { DEFAULT_RELAY_PORT, DEFAULT_RECONNECT_INTERVAL_MS, RELAY_TOKEN_PARAM, + UPGRADE_RETRY_DELAY_MS, + MAX_UPGRADE_RETRIES, } from "./protocol.js"; export interface RelayClient { @@ -54,9 +56,6 @@ const getHealthCheckUrl = (wsUrl: string, token?: string): string => { return url.toString(); }; -const UPGRADE_RETRY_DELAY_MS = 1000; -const MAX_UPGRADE_RETRIES = 5; - export const createRelayClient = ( options: RelayClientOptions = {}, ): RelayClient => { @@ -126,10 +125,13 @@ export const createRelayClient = ( } return; } catch { - await new Promise((resolve) => - setTimeout(resolve, UPGRADE_RETRY_DELAY_MS), - ); - continue; + if (attempt < MAX_UPGRADE_RETRIES - 1) { + await new Promise((resolve) => + setTimeout(resolve, UPGRADE_RETRY_DELAY_MS), + ); + continue; + } + return; } } }; diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index ada246854..85b755718 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -36,6 +36,7 @@ const checkIfRelayServerIsRunning = async ( : `${httpProtocol}://localhost:${port}/health`; if (secure) { + // HACK: rejectUnauthorized: false is needed because mkcert generates self-signed certs const https = await import("node:https"); return new Promise((resolve) => { const request = https.get( @@ -104,6 +105,7 @@ export const connectRelay = async ( printStartupMessage(handler.agentId, relayPort, true); } catch (error) { + // HACK: process.exit(1) because the old HTTP server is already stopped, so fallback isn't possible console.error( pc.red("Failed to upgrade to HTTPS:"), error instanceof Error ? error.message : error, @@ -203,6 +205,7 @@ const connectToExistingRelay = async ( const connectionUrl = token ? `${webSocketProtocol}://localhost:${port}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` : `${webSocketProtocol}://localhost:${port}`; + // HACK: rejectUnauthorized: false is needed because mkcert generates self-signed certs const socket = new WebSocket(connectionUrl, { headers: { "x-relay-handler": "true" }, rejectUnauthorized: false, diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts index 3d3833a75..801e35ac0 100644 --- a/packages/relay/src/mkcert.ts +++ b/packages/relay/src/mkcert.ts @@ -25,7 +25,6 @@ interface GithubReleaseAsset { } interface GithubReleaseResponse { - tag_name: string; assets: GithubReleaseAsset[]; } diff --git a/packages/relay/src/protocol.ts b/packages/relay/src/protocol.ts index ceaaef4ad..63901f98f 100644 --- a/packages/relay/src/protocol.ts +++ b/packages/relay/src/protocol.ts @@ -4,6 +4,8 @@ export const HEALTH_CHECK_TIMEOUT_MS = 1000; export const POST_KILL_DELAY_MS = 100; export const RELAY_TOKEN_PARAM = "token"; export const COMPLETED_STATUS = "Completed"; +export const UPGRADE_RETRY_DELAY_MS = 1000; +export const MAX_UPGRADE_RETRIES = 5; export interface AgentMessage { type: "status" | "error" | "done"; diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index ec44a2fa7..bc6f2da47 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -557,7 +557,12 @@ export const createRelayServer = ( handlers: getRegisteredHandlerIds(), }), ); - onSecureUpgradeRequested().catch(() => {}); + onSecureUpgradeRequested().catch((error) => { + console.error( + "Failed to upgrade to secure connection:", + error instanceof Error ? error.message : error, + ); + }); return; } From d4e954063118eccc84c4b2783f6cdbe236c6cf6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Feb 2026 23:34:49 +0000 Subject: [PATCH 18/26] Fix relay server auto-upgrade bugs and reconnection issues - Add auto-reconnection logic for remote handlers when connection is lost - Add protocol fallback (HTTP/HTTPS) in health checks for better auto-upgrade support - Throw error when server readiness check times out after max retries - Check isIntentionalDisconnect flag before creating WebSocket to prevent race condition - Apply formatting changes from oxfmt --- packages/relay/src/client.ts | 6 + packages/relay/src/connection.ts | 399 +++++++++++++++++-------------- 2 files changed, 226 insertions(+), 179 deletions(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index d957116b1..9c423df30 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -134,6 +134,8 @@ export const createRelayClient = ( return; } } + + throw new Error("Server upgrade timed out after maximum retries"); }; const connect = (): Promise => { @@ -150,6 +152,10 @@ export const createRelayClient = ( pendingConnectionPromise = (async () => { await ensureServerReady(); + if (isIntentionalDisconnect) { + throw new Error("Connection aborted"); + } + return new Promise((resolve, reject) => { pendingConnectionReject = reject; const connectionUrl = token diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 85b755718..aefe75235 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -29,37 +29,49 @@ const checkIfRelayServerIsRunning = async ( token?: string, secure?: boolean, ): Promise => { - try { - const httpProtocol = secure ? "https" : "http"; - const healthUrl = token - ? `${httpProtocol}://localhost:${port}/health?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : `${httpProtocol}://localhost:${port}/health`; - - if (secure) { - // HACK: rejectUnauthorized: false is needed because mkcert generates self-signed certs - const https = await import("node:https"); - return new Promise((resolve) => { - const request = https.get( - healthUrl, - { rejectUnauthorized: false, timeout: HEALTH_CHECK_TIMEOUT_MS }, - (response) => resolve(response.statusCode === 200), - ); - request.on("error", () => resolve(false)); - request.on("timeout", () => { - request.destroy(); - resolve(false); + const tryProtocol = async (useHttps: boolean): Promise => { + try { + const httpProtocol = useHttps ? "https" : "http"; + const healthUrl = token + ? `${httpProtocol}://localhost:${port}/health?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : `${httpProtocol}://localhost:${port}/health`; + + if (useHttps) { + // HACK: rejectUnauthorized: false is needed because mkcert generates self-signed certs + const https = await import("node:https"); + return new Promise((resolve) => { + const request = https.get( + healthUrl, + { rejectUnauthorized: false, timeout: HEALTH_CHECK_TIMEOUT_MS }, + (response) => resolve(response.statusCode === 200), + ); + request.on("error", () => resolve(false)); + request.on("timeout", () => { + request.destroy(); + resolve(false); + }); }); + } + + const response = await fetch(healthUrl, { + method: "GET", + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), }); + return response.ok; + } catch { + return false; } + }; - const response = await fetch(healthUrl, { - method: "GET", - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), - }); - return response.ok; - } catch { - return false; + if (secure !== undefined) { + const result = await tryProtocol(secure); + if (result) return true; + return await tryProtocol(!secure); } + + const httpResult = await tryProtocol(false); + if (httpResult) return true; + return await tryProtocol(true); }; export const connectRelay = async ( @@ -200,88 +212,133 @@ const connectToExistingRelay = async ( ): Promise => { const { WebSocket } = await import("ws"); - return new Promise((resolve, reject) => { - const webSocketProtocol = secure ? "wss" : "ws"; - const connectionUrl = token - ? `${webSocketProtocol}://localhost:${port}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : `${webSocketProtocol}://localhost:${port}`; - // HACK: rejectUnauthorized: false is needed because mkcert generates self-signed certs - const socket = new WebSocket(connectionUrl, { - headers: { "x-relay-handler": "true" }, - rejectUnauthorized: false, - }); + let currentSocket: typeof WebSocket.prototype | null = null; + let isSocketClosed = false; + let isExplicitDisconnect = false; + const activeSessionIds = new Set(); + + const sendData = (data: string): boolean => { + if ( + isSocketClosed || + !currentSocket || + currentSocket.readyState !== WebSocket.OPEN + ) { + return false; + } + try { + currentSocket.send(data); + return true; + } catch { + return false; + } + }; - socket.on("open", () => { - let isSocketClosed = false; - const activeSessionIds = new Set(); - - const sendData = (data: string): boolean => { - if (isSocketClosed || socket.readyState !== WebSocket.OPEN) { - return false; - } - try { - socket.send(data); - return true; - } catch { - return false; - } - }; - - socket.on("close", () => { - isSocketClosed = true; - for (const sessionId of activeSessionIds) { - try { - handler.abort?.(sessionId); - } catch {} - } - activeSessionIds.clear(); + const attemptConnection = (): Promise => { + return new Promise((resolve, reject) => { + const webSocketProtocol = secure ? "wss" : "ws"; + const connectionUrl = token + ? `${webSocketProtocol}://localhost:${port}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : `${webSocketProtocol}://localhost:${port}`; + // HACK: rejectUnauthorized: false is needed because mkcert generates self-signed certs + const socket = new WebSocket(connectionUrl, { + headers: { "x-relay-handler": "true" }, + rejectUnauthorized: false, }); - socket.send( - JSON.stringify({ - type: "register-handler", - agentId: handler.agentId, - }), - ); + socket.on("open", () => { + currentSocket = socket; + isSocketClosed = false; - socket.on("message", async (data) => { - try { - const message = JSON.parse(data.toString()); - - if (message.type === "invoke-handler") { - const { method, sessionId, payload } = message; - - if (method === "run" && payload?.prompt) { - activeSessionIds.add(sessionId); - try { - let didComplete = false; - for await (const agentMessage of handler.run(payload.prompt, { - sessionId, - })) { - if (isSocketClosed) { - break; + socket.on("close", () => { + isSocketClosed = true; + for (const sessionId of activeSessionIds) { + try { + handler.abort?.(sessionId); + } catch {} + } + activeSessionIds.clear(); + + if (!isExplicitDisconnect) { + setTimeout(() => { + attemptConnection().catch(() => {}); + }, 1000); + } + }); + + socket.send( + JSON.stringify({ + type: "register-handler", + agentId: handler.agentId, + }), + ); + + socket.on("message", async (data) => { + try { + const message = JSON.parse(data.toString()); + + if (message.type === "invoke-handler") { + const { method, sessionId, payload } = message; + + if (method === "run" && payload?.prompt) { + activeSessionIds.add(sessionId); + try { + let didComplete = false; + for await (const agentMessage of handler.run(payload.prompt, { + sessionId, + })) { + if (isSocketClosed) { + break; + } + sendData( + JSON.stringify({ + type: + agentMessage.type === "status" + ? "agent-status" + : agentMessage.type === "error" + ? "agent-error" + : "agent-done", + sessionId, + agentId: handler.agentId, + content: agentMessage.content, + }), + ); + if ( + agentMessage.type === "done" || + agentMessage.type === "error" + ) { + didComplete = true; + } + } + if (!didComplete && !isSocketClosed) { + sendData( + JSON.stringify({ + type: "agent-done", + sessionId, + agentId: handler.agentId, + content: "", + }), + ); } + } catch (error) { sendData( JSON.stringify({ - type: - agentMessage.type === "status" - ? "agent-status" - : agentMessage.type === "error" - ? "agent-error" - : "agent-done", + type: "agent-error", sessionId, agentId: handler.agentId, - content: agentMessage.content, + content: + error instanceof Error + ? error.message + : "Unknown error", }), ); - if ( - agentMessage.type === "done" || - agentMessage.type === "error" - ) { - didComplete = true; - } + } finally { + activeSessionIds.delete(sessionId); } - if (!didComplete && !isSocketClosed) { + } else if (method === "abort") { + handler.abort?.(sessionId); + } else if (method === "undo") { + try { + await handler.undo?.(); sendData( JSON.stringify({ type: "agent-done", @@ -290,96 +347,80 @@ const connectToExistingRelay = async ( content: "", }), ); + } catch (error) { + sendData( + JSON.stringify({ + type: "agent-error", + sessionId, + agentId: handler.agentId, + content: + error instanceof Error + ? error.message + : "Unknown error", + }), + ); + } + } else if (method === "redo") { + try { + await handler.redo?.(); + sendData( + JSON.stringify({ + type: "agent-done", + sessionId, + agentId: handler.agentId, + content: "", + }), + ); + } catch (error) { + sendData( + JSON.stringify({ + type: "agent-error", + sessionId, + agentId: handler.agentId, + content: + error instanceof Error + ? error.message + : "Unknown error", + }), + ); } - } catch (error) { - sendData( - JSON.stringify({ - type: "agent-error", - sessionId, - agentId: handler.agentId, - content: - error instanceof Error ? error.message : "Unknown error", - }), - ); - } finally { - activeSessionIds.delete(sessionId); - } - } else if (method === "abort") { - handler.abort?.(sessionId); - } else if (method === "undo") { - try { - await handler.undo?.(); - sendData( - JSON.stringify({ - type: "agent-done", - sessionId, - agentId: handler.agentId, - content: "", - }), - ); - } catch (error) { - sendData( - JSON.stringify({ - type: "agent-error", - sessionId, - agentId: handler.agentId, - content: - error instanceof Error ? error.message : "Unknown error", - }), - ); - } - } else if (method === "redo") { - try { - await handler.redo?.(); - sendData( - JSON.stringify({ - type: "agent-done", - sessionId, - agentId: handler.agentId, - content: "", - }), - ); - } catch (error) { - sendData( - JSON.stringify({ - type: "agent-error", - sessionId, - agentId: handler.agentId, - content: - error instanceof Error ? error.message : "Unknown error", - }), - ); } } - } - } catch {} - }); + } catch {} + }); - const proxyServer: RelayServer = { - start: async () => {}, - stop: async () => { - socket.close(); - }, - registerHandler: () => {}, - unregisterHandler: (agentId) => { - sendData( - JSON.stringify({ - type: "unregister-handler", - agentId, - }), - ); - socket.close(); - }, - getRegisteredHandlerIds: () => [handler.agentId], - }; + resolve(); + }); - resolve(proxyServer); + socket.on("error", (error) => { + reject(error); + }); }); + }; - socket.on("error", (error) => { - reject(error); - }); - }); + await attemptConnection(); + + const proxyServer: RelayServer = { + start: async () => {}, + stop: async () => { + isExplicitDisconnect = true; + currentSocket?.close(); + }, + registerHandler: () => {}, + unregisterHandler: (agentId) => { + isExplicitDisconnect = true; + sendData( + JSON.stringify({ + type: "unregister-handler", + agentId, + }), + ); + currentSocket?.close(); + }, + getRegisteredHandlerIds: () => [handler.agentId], + }; + + return proxyServer; }; const printStartupMessage = ( From 8b48e7c5f7ea4a2f19f7d943b1e8d9c3decaabdd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Feb 2026 23:49:07 +0000 Subject: [PATCH 19/26] Fix WebSocket connection and reconnection reliability issues - Move close handler outside open handler in attemptConnection to ensure reconnection retries work even when connection attempts fail - Return detected protocol from health check to prevent WebSocket protocol mismatch when secure parameter is undefined - Add try-catch in connect() to clear stale rejected promise and prevent permanent connection failure --- packages/relay/src/client.ts | 96 +++++++++++++++++--------------- packages/relay/src/connection.ts | 54 ++++++++++-------- 2 files changed, 83 insertions(+), 67 deletions(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index 9c423df30..fe69302c5 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -150,55 +150,61 @@ export const createRelayClient = ( isIntentionalDisconnect = false; pendingConnectionPromise = (async () => { - await ensureServerReady(); - - if (isIntentionalDisconnect) { - throw new Error("Connection aborted"); - } + try { + await ensureServerReady(); - return new Promise((resolve, reject) => { - pendingConnectionReject = reject; - const connectionUrl = token - ? `${serverUrl}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` - : serverUrl; - webSocketConnection = new WebSocket(connectionUrl); - - webSocketConnection.onopen = () => { - pendingConnectionPromise = null; - pendingConnectionReject = null; - isConnectedState = true; - for (const callback of connectionChangeCallbacks) { - callback(true); - } - resolve(); - }; + if (isIntentionalDisconnect) { + throw new Error("Connection aborted"); + } - webSocketConnection.onmessage = handleMessage; + return new Promise((resolve, reject) => { + pendingConnectionReject = reject; + const connectionUrl = token + ? `${serverUrl}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` + : serverUrl; + webSocketConnection = new WebSocket(connectionUrl); - webSocketConnection.onclose = () => { - if (pendingConnectionReject) { - pendingConnectionReject(new Error("WebSocket connection closed")); + webSocketConnection.onopen = () => { + pendingConnectionPromise = null; pendingConnectionReject = null; - } - pendingConnectionPromise = null; - isConnectedState = false; - availableHandlers = []; - for (const callback of handlersChangeCallbacks) { - callback(availableHandlers); - } - for (const callback of connectionChangeCallbacks) { - callback(false); - } - scheduleReconnect(); - }; - - webSocketConnection.onerror = () => { - pendingConnectionPromise = null; - pendingConnectionReject = null; - isConnectedState = false; - reject(new Error("WebSocket connection failed")); - }; - }); + isConnectedState = true; + for (const callback of connectionChangeCallbacks) { + callback(true); + } + resolve(); + }; + + webSocketConnection.onmessage = handleMessage; + + webSocketConnection.onclose = () => { + if (pendingConnectionReject) { + pendingConnectionReject(new Error("WebSocket connection closed")); + pendingConnectionReject = null; + } + pendingConnectionPromise = null; + isConnectedState = false; + availableHandlers = []; + for (const callback of handlersChangeCallbacks) { + callback(availableHandlers); + } + for (const callback of connectionChangeCallbacks) { + callback(false); + } + scheduleReconnect(); + }; + + webSocketConnection.onerror = () => { + pendingConnectionPromise = null; + pendingConnectionReject = null; + isConnectedState = false; + reject(new Error("WebSocket connection failed")); + }; + }); + } catch (error) { + pendingConnectionPromise = null; + pendingConnectionReject = null; + throw error; + } })(); return pendingConnectionPromise; diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index aefe75235..30d731af7 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -28,7 +28,7 @@ const checkIfRelayServerIsRunning = async ( port: number, token?: string, secure?: boolean, -): Promise => { +): Promise => { const tryProtocol = async (useHttps: boolean): Promise => { try { const httpProtocol = useHttps ? "https" : "http"; @@ -70,8 +70,10 @@ const checkIfRelayServerIsRunning = async ( } const httpResult = await tryProtocol(false); - if (httpResult) return true; - return await tryProtocol(true); + if (httpResult) return { isRunning: true, isSecure: false }; + const httpsResult = await tryProtocol(true); + if (httpsResult) return { isRunning: true, isSecure: true }; + return false; }; export const connectRelay = async ( @@ -83,13 +85,21 @@ export const connectRelay = async ( let relayServer: RelayServer | null = null; let isRelayHost = false; - const isRelayServerRunning = await checkIfRelayServerIsRunning( + const healthCheckResult = await checkIfRelayServerIsRunning( relayPort, token, secure, ); - let isSecureMode = secure ?? false; + const isRelayServerRunning = + healthCheckResult === true || + (typeof healthCheckResult === "object" && healthCheckResult.isRunning); + const detectedSecure = + typeof healthCheckResult === "object" + ? healthCheckResult.isSecure + : undefined; + + let isSecureMode = secure ?? detectedSecure ?? false; const startServer = async (useSecure: boolean): Promise => { const onSecureUpgradeRequested = async () => { @@ -169,7 +179,7 @@ export const connectRelay = async ( relayPort, handler, token, - secure, + detectedSecure ?? secure, ); } else { await fkill(`:${relayPort}`, { force: true, silent: true }).catch(() => {}); @@ -245,26 +255,26 @@ const connectToExistingRelay = async ( rejectUnauthorized: false, }); + socket.on("close", () => { + isSocketClosed = true; + for (const sessionId of activeSessionIds) { + try { + handler.abort?.(sessionId); + } catch {} + } + activeSessionIds.clear(); + + if (!isExplicitDisconnect) { + setTimeout(() => { + attemptConnection().catch(() => {}); + }, 1000); + } + }); + socket.on("open", () => { currentSocket = socket; isSocketClosed = false; - socket.on("close", () => { - isSocketClosed = true; - for (const sessionId of activeSessionIds) { - try { - handler.abort?.(sessionId); - } catch {} - } - activeSessionIds.clear(); - - if (!isExplicitDisconnect) { - setTimeout(() => { - attemptConnection().catch(() => {}); - }, 1000); - } - }); - socket.send( JSON.stringify({ type: "register-handler", From abc9913ad52322119e84d396660d10f94777010e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Feb 2026 00:09:31 +0000 Subject: [PATCH 20/26] fix: resolve protocol fallback, network error handling, and reconnect loop bugs - Fix protocol fallback to return isSecure info even when secure param is explicitly set - Fix ensureServerReady to throw on network errors after max retries instead of silent return - Fix orphaned reconnect loop by tracking hasConnectedOnce flag to prevent reconnects on initial connection failure --- packages/relay/src/client.ts | 1 - packages/relay/src/connection.ts | 10 +++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index fe69302c5..742a501d2 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -131,7 +131,6 @@ export const createRelayClient = ( ); continue; } - return; } } diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 30d731af7..58c713af9 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -65,8 +65,10 @@ const checkIfRelayServerIsRunning = async ( if (secure !== undefined) { const result = await tryProtocol(secure); - if (result) return true; - return await tryProtocol(!secure); + if (result) return { isRunning: true, isSecure: secure }; + const fallbackResult = await tryProtocol(!secure); + if (fallbackResult) return { isRunning: true, isSecure: !secure }; + return false; } const httpResult = await tryProtocol(false); @@ -225,6 +227,7 @@ const connectToExistingRelay = async ( let currentSocket: typeof WebSocket.prototype | null = null; let isSocketClosed = false; let isExplicitDisconnect = false; + let hasConnectedOnce = false; const activeSessionIds = new Set(); const sendData = (data: string): boolean => { @@ -264,7 +267,7 @@ const connectToExistingRelay = async ( } activeSessionIds.clear(); - if (!isExplicitDisconnect) { + if (!isExplicitDisconnect && hasConnectedOnce) { setTimeout(() => { attemptConnection().catch(() => {}); }, 1000); @@ -274,6 +277,7 @@ const connectToExistingRelay = async ( socket.on("open", () => { currentSocket = socket; isSocketClosed = false; + hasConnectedOnce = true; socket.send( JSON.stringify({ From 3213357c19f3a81064e0c26abb2bfdc90fd05591 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Feb 2026 00:25:11 +0000 Subject: [PATCH 21/26] Fix startup message showing wrong protocol when connecting to existing relay --- packages/relay/src/connection.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 58c713af9..55d826f5b 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -177,12 +177,14 @@ export const connectRelay = async ( }; if (isRelayServerRunning) { + const actualSecure = detectedSecure ?? secure; relayServer = await connectToExistingRelay( relayPort, handler, token, - detectedSecure ?? secure, + actualSecure, ); + isSecureMode = actualSecure ?? false; } else { await fkill(`:${relayPort}`, { force: true, silent: true }).catch(() => {}); await sleep(POST_KILL_DELAY_MS); From 4b5ea7463eb0d54c69d2f5063b88be498dda489b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 8 Feb 2026 16:02:30 -0800 Subject: [PATCH 22/26] Refactor relay package for clarity and consistency Co-authored-by: Cursor --- packages/relay/src/client.ts | 83 +++++++++++++++------- packages/relay/src/connection.ts | 117 +++++++++++++++---------------- packages/relay/src/protocol.ts | 3 +- 3 files changed, 115 insertions(+), 88 deletions(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index 742a501d2..f978118f8 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -11,6 +11,11 @@ import { MAX_UPGRADE_RETRIES, } from "./protocol.js"; +interface HealthCheckResponse { + status: string; + secure?: boolean; +} + export interface RelayClient { connect: () => Promise; disconnect: () => void; @@ -43,9 +48,12 @@ const getDefaultWebSocketUrl = (): string => { return `${protocol}//${window.location.hostname}:${DEFAULT_RELAY_PORT}`; }; -const getHealthCheckUrl = (wsUrl: string, token?: string): string => { - const httpProtocol = wsUrl.startsWith("wss:") ? "https:" : "http:"; - const url = new URL(wsUrl.replace(/^wss?:/, httpProtocol)); +const buildHealthUrl = ( + baseWsUrl: string, + protocol: "http:" | "https:", + token?: string, +): string => { + const url = new URL(baseWsUrl.replace(/^wss?:/, protocol)); url.pathname = "/health"; if (isSecureContext()) { url.searchParams.set("secure", "true"); @@ -104,34 +112,57 @@ export const createRelayClient = ( }; const ensureServerReady = async (): Promise => { - const healthUrl = getHealthCheckUrl(serverUrl, token); + const httpHealthUrl = buildHealthUrl(serverUrl, "http:", token); + const httpsHealthUrl = buildHealthUrl(serverUrl, "https:", token); + const needsUpgrade = isSecureContext(); - for (let attempt = 0; attempt < MAX_UPGRADE_RETRIES; attempt++) { - try { - const response = await fetch(healthUrl); - if (!response.ok) { - await new Promise((resolve) => - setTimeout(resolve, UPGRADE_RETRY_DELAY_MS), - ); - continue; - } + let didTriggerUpgrade = false; - const data = (await response.json()) as { status: string }; - if (data.status === "upgrading") { - await new Promise((resolve) => - setTimeout(resolve, UPGRADE_RETRY_DELAY_MS), - ); - continue; - } - return; + const delay = (): Promise => + new Promise((resolve) => setTimeout(resolve, UPGRADE_RETRY_DELAY_MS)); + + const tryFetch = async ( + url: string, + ): Promise => { + try { + const response = await fetch(url); + if (!response.ok) return null; + return (await response.json()) as HealthCheckResponse; } catch { - if (attempt < MAX_UPGRADE_RETRIES - 1) { - await new Promise((resolve) => - setTimeout(resolve, UPGRADE_RETRY_DELAY_MS), - ); - continue; + return null; + } + }; + + for (let attempt = 0; attempt < MAX_UPGRADE_RETRIES; attempt++) { + if (!needsUpgrade) { + const healthResponse = await tryFetch(httpHealthUrl); + if (healthResponse && healthResponse.status === "ok") return; + await delay(); + continue; + } + + // HACK: When in a secure context, use HTTP to trigger the upgrade, + // then switch to HTTPS once the server has restarted + if (!didTriggerUpgrade) { + const healthResponse = await tryFetch(httpHealthUrl); + if (healthResponse) { + if ( + healthResponse.status === "upgrading" || + (!healthResponse.secure && needsUpgrade) + ) { + didTriggerUpgrade = true; + await delay(); + continue; + } + if (healthResponse.status === "ok" && healthResponse.secure) return; } + await delay(); + continue; } + + const healthResponse = await tryFetch(httpsHealthUrl); + if (healthResponse && healthResponse.status === "ok" && healthResponse.secure) return; + await delay(); } throw new Error("Server upgrade timed out after maximum retries"); diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 55d826f5b..efa7ad5ec 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -3,6 +3,7 @@ import fkill from "fkill"; import type { AgentHandler } from "./protocol.js"; import { DEFAULT_RELAY_PORT, + HANDLER_RECONNECT_DELAY_MS, HEALTH_CHECK_TIMEOUT_MS, POST_KILL_DELAY_MS, RELAY_TOKEN_PARAM, @@ -12,6 +13,14 @@ import { sleep } from "@react-grab/utils/server"; const VERSION = process.env.VERSION ?? "0.0.0"; +const getRelayMessageType = ( + agentMessageType: string, +): "agent-status" | "agent-error" | "agent-done" => { + if (agentMessageType === "status") return "agent-status"; + if (agentMessageType === "error") return "agent-error"; + return "agent-done"; +}; + interface ConnectRelayOptions { port?: number; handler: AgentHandler; @@ -24,11 +33,16 @@ interface RelayConnection { disconnect: () => Promise; } +interface HealthCheckResult { + isRunning: true; + isSecure: boolean; +} + const checkIfRelayServerIsRunning = async ( port: number, token?: string, secure?: boolean, -): Promise => { +): Promise => { const tryProtocol = async (useHttps: boolean): Promise => { try { const httpProtocol = useHttps ? "https" : "http"; @@ -93,13 +107,10 @@ export const connectRelay = async ( secure, ); - const isRelayServerRunning = - healthCheckResult === true || - (typeof healthCheckResult === "object" && healthCheckResult.isRunning); - const detectedSecure = - typeof healthCheckResult === "object" - ? healthCheckResult.isSecure - : undefined; + const isRelayServerRunning = Boolean(healthCheckResult); + const detectedSecure = healthCheckResult + ? healthCheckResult.isSecure + : undefined; let isSecureMode = secure ?? detectedSecure ?? false; @@ -248,6 +259,33 @@ const connectToExistingRelay = async ( } }; + const sendOperationResult = async ( + operation: () => Promise | undefined, + sessionId: string, + ): Promise => { + try { + await operation(); + sendData( + JSON.stringify({ + type: "agent-done", + sessionId, + agentId: handler.agentId, + content: "", + }), + ); + } catch (error) { + sendData( + JSON.stringify({ + type: "agent-error", + sessionId, + agentId: handler.agentId, + content: + error instanceof Error ? error.message : "Unknown error", + }), + ); + } + }; + const attemptConnection = (): Promise => { return new Promise((resolve, reject) => { const webSocketProtocol = secure ? "wss" : "ws"; @@ -272,7 +310,7 @@ const connectToExistingRelay = async ( if (!isExplicitDisconnect && hasConnectedOnce) { setTimeout(() => { attemptConnection().catch(() => {}); - }, 1000); + }, HANDLER_RECONNECT_DELAY_MS); } }); @@ -307,12 +345,7 @@ const connectToExistingRelay = async ( } sendData( JSON.stringify({ - type: - agentMessage.type === "status" - ? "agent-status" - : agentMessage.type === "error" - ? "agent-error" - : "agent-done", + type: getRelayMessageType(agentMessage.type), sessionId, agentId: handler.agentId, content: agentMessage.content, @@ -353,53 +386,15 @@ const connectToExistingRelay = async ( } else if (method === "abort") { handler.abort?.(sessionId); } else if (method === "undo") { - try { - await handler.undo?.(); - sendData( - JSON.stringify({ - type: "agent-done", - sessionId, - agentId: handler.agentId, - content: "", - }), - ); - } catch (error) { - sendData( - JSON.stringify({ - type: "agent-error", - sessionId, - agentId: handler.agentId, - content: - error instanceof Error - ? error.message - : "Unknown error", - }), - ); - } + await sendOperationResult( + async () => handler.undo?.(), + sessionId, + ); } else if (method === "redo") { - try { - await handler.redo?.(); - sendData( - JSON.stringify({ - type: "agent-done", - sessionId, - agentId: handler.agentId, - content: "", - }), - ); - } catch (error) { - sendData( - JSON.stringify({ - type: "agent-error", - sessionId, - agentId: handler.agentId, - content: - error instanceof Error - ? error.message - : "Unknown error", - }), - ); - } + await sendOperationResult( + async () => handler.redo?.(), + sessionId, + ); } } } catch {} diff --git a/packages/relay/src/protocol.ts b/packages/relay/src/protocol.ts index 63901f98f..b75e3f8dc 100644 --- a/packages/relay/src/protocol.ts +++ b/packages/relay/src/protocol.ts @@ -5,7 +5,8 @@ export const POST_KILL_DELAY_MS = 100; export const RELAY_TOKEN_PARAM = "token"; export const COMPLETED_STATUS = "Completed"; export const UPGRADE_RETRY_DELAY_MS = 1000; -export const MAX_UPGRADE_RETRIES = 5; +export const MAX_UPGRADE_RETRIES = 15; +export const HANDLER_RECONNECT_DELAY_MS = 1000; export interface AgentMessage { type: "status" | "error" | "done"; From f2f42ab445f0f71699c13d4719ef77eb2945ddae Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 8 Feb 2026 16:58:47 -0800 Subject: [PATCH 23/26] fix --- packages/gym/.gitignore | 2 ++ packages/relay/src/mkcert.ts | 16 +++++++++++++++- packages/relay/src/server.ts | 23 ++++++++++++++++++++--- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/gym/.gitignore b/packages/gym/.gitignore index a680367ef..1288e0d39 100644 --- a/packages/gym/.gitignore +++ b/packages/gym/.gitignore @@ -1 +1,3 @@ .next + +certificates \ No newline at end of file diff --git a/packages/relay/src/mkcert.ts b/packages/relay/src/mkcert.ts index 801e35ac0..cbb0395dd 100644 --- a/packages/relay/src/mkcert.ts +++ b/packages/relay/src/mkcert.ts @@ -17,7 +17,21 @@ const KEY_PATH = join(DATA_DIR, "localhost-key.pem"); const CERT_PATH = join(DATA_DIR, "localhost.pem"); const HOSTS_METADATA_PATH = join(DATA_DIR, "cert-hosts.json"); -const MKCERT_ENV = { env: { ...process.env, CAROOT: DATA_DIR } }; +const getDefaultCaRoot = (): string => { + if (platform() === "win32") { + const localAppData = + process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"); + return join(localAppData, "mkcert"); + } + if (platform() === "darwin") { + return join(homedir(), "Library", "Application Support", "mkcert"); + } + const dataHome = + process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"); + return join(dataHome, "mkcert"); +}; + +const MKCERT_ENV = { env: { ...process.env, CAROOT: getDefaultCaRoot() } }; interface GithubReleaseAsset { name: string; diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index bc6f2da47..26cbdea7e 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -534,14 +534,31 @@ export const createRelayServer = ( const certificate = secure ? await ensureCertificates(certHostnames) : null; return new Promise((resolve, reject) => { + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }; + const requestHandler = (req: IncomingMessage, res: ServerResponse) => { const requestUrl = new URL(req.url ?? "", `http://localhost:${port}`); + if (req.method === "OPTIONS") { + res.writeHead(204, corsHeaders); + res.end(); + return; + } + if (requestUrl.pathname === "/health") { + const healthHeaders = { + "Content-Type": "application/json", + ...corsHeaders, + }; + if (token) { const clientToken = requestUrl.searchParams.get(RELAY_TOKEN_PARAM); if (clientToken !== token) { - res.writeHead(401, { "Content-Type": "application/json" }); + res.writeHead(401, healthHeaders); res.end(JSON.stringify({ error: "Unauthorized" })); return; } @@ -550,7 +567,7 @@ export const createRelayServer = ( const clientNeedsSecure = requestUrl.searchParams.get("secure") === "true"; if (clientNeedsSecure && !secure && onSecureUpgradeRequested) { - res.writeHead(200, { "Content-Type": "application/json" }); + res.writeHead(200, healthHeaders); res.end( JSON.stringify({ status: "upgrading", @@ -566,7 +583,7 @@ export const createRelayServer = ( return; } - res.writeHead(200, { "Content-Type": "application/json" }); + res.writeHead(200, healthHeaders); res.end( JSON.stringify({ status: "ok", From e2e98d8cb3a9df1396ebab936544d39d8e7aef64 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Feb 2026 01:15:09 +0000 Subject: [PATCH 24/26] Fix protocol upgrade and reconnection bugs - Fix Bug 1: HTTPS endpoint now tried when HTTP fails in secure context by tracking consecutive HTTP failures and falling back to HTTPS after threshold - Fix Bug 2: Handler reconnects with correct protocol after server upgrade by dynamically checking server protocol before reconnection attempts --- packages/relay/src/client.ts | 15 ++++++++++++++- packages/relay/src/connection.ts | 19 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/relay/src/client.ts b/packages/relay/src/client.ts index f978118f8..394dac277 100644 --- a/packages/relay/src/client.ts +++ b/packages/relay/src/client.ts @@ -117,6 +117,8 @@ export const createRelayClient = ( const needsUpgrade = isSecureContext(); let didTriggerUpgrade = false; + let consecutiveHttpFailures = 0; + const HTTP_FAILURE_THRESHOLD = 3; const delay = (): Promise => new Promise((resolve) => setTimeout(resolve, UPGRADE_RETRY_DELAY_MS)); @@ -146,6 +148,7 @@ export const createRelayClient = ( if (!didTriggerUpgrade) { const healthResponse = await tryFetch(httpHealthUrl); if (healthResponse) { + consecutiveHttpFailures = 0; if ( healthResponse.status === "upgrading" || (!healthResponse.secure && needsUpgrade) @@ -155,13 +158,23 @@ export const createRelayClient = ( continue; } if (healthResponse.status === "ok" && healthResponse.secure) return; + } else { + consecutiveHttpFailures++; + if (consecutiveHttpFailures >= HTTP_FAILURE_THRESHOLD) { + didTriggerUpgrade = true; + } } await delay(); continue; } const healthResponse = await tryFetch(httpsHealthUrl); - if (healthResponse && healthResponse.status === "ok" && healthResponse.secure) return; + if ( + healthResponse && + healthResponse.status === "ok" && + healthResponse.secure + ) + return; await delay(); } diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index efa7ad5ec..33dd6883c 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -242,6 +242,7 @@ const connectToExistingRelay = async ( let isExplicitDisconnect = false; let hasConnectedOnce = false; const activeSessionIds = new Set(); + let currentSecure = secure; const sendData = (data: string): boolean => { if ( @@ -279,16 +280,26 @@ const connectToExistingRelay = async ( type: "agent-error", sessionId, agentId: handler.agentId, - content: - error instanceof Error ? error.message : "Unknown error", + content: error instanceof Error ? error.message : "Unknown error", }), ); } }; - const attemptConnection = (): Promise => { + const attemptConnection = async (): Promise => { + if (hasConnectedOnce) { + const healthCheckResult = await checkIfRelayServerIsRunning( + port, + token, + currentSecure, + ); + if (healthCheckResult) { + currentSecure = healthCheckResult.isSecure; + } + } + return new Promise((resolve, reject) => { - const webSocketProtocol = secure ? "wss" : "ws"; + const webSocketProtocol = currentSecure ? "wss" : "ws"; const connectionUrl = token ? `${webSocketProtocol}://localhost:${port}?${RELAY_TOKEN_PARAM}=${encodeURIComponent(token)}` : `${webSocketProtocol}://localhost:${port}`; From e9a091b3f0cf13b9aaf6da5f5c783a76c5d9c73d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Feb 2026 01:29:59 +0000 Subject: [PATCH 25/26] fix(relay): prioritize user secure preference and cleanup reconnect timeout - Fix secure flag being silently ignored when connecting to existing server by prioritizing user's explicit --secure preference over detected mode - Fix resource leak by storing and clearing reconnection timeout ID in stop() and unregisterHandler() to prevent orphan connections after explicit disconnect --- packages/relay/src/connection.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index 33dd6883c..a351355f7 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -188,7 +188,7 @@ export const connectRelay = async ( }; if (isRelayServerRunning) { - const actualSecure = detectedSecure ?? secure; + const actualSecure = secure ?? detectedSecure; relayServer = await connectToExistingRelay( relayPort, handler, @@ -243,6 +243,7 @@ const connectToExistingRelay = async ( let hasConnectedOnce = false; const activeSessionIds = new Set(); let currentSecure = secure; + let reconnectTimeoutId: NodeJS.Timeout | null = null; const sendData = (data: string): boolean => { if ( @@ -319,7 +320,7 @@ const connectToExistingRelay = async ( activeSessionIds.clear(); if (!isExplicitDisconnect && hasConnectedOnce) { - setTimeout(() => { + reconnectTimeoutId = setTimeout(() => { attemptConnection().catch(() => {}); }, HANDLER_RECONNECT_DELAY_MS); } @@ -426,11 +427,19 @@ const connectToExistingRelay = async ( start: async () => {}, stop: async () => { isExplicitDisconnect = true; + if (reconnectTimeoutId) { + clearTimeout(reconnectTimeoutId); + reconnectTimeoutId = null; + } currentSocket?.close(); }, registerHandler: () => {}, unregisterHandler: (agentId) => { isExplicitDisconnect = true; + if (reconnectTimeoutId) { + clearTimeout(reconnectTimeoutId); + reconnectTimeoutId = null; + } sendData( JSON.stringify({ type: "unregister-handler", From c2472f4261378eaf366e7bfa2f3eae9cc00271ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Feb 2026 01:45:36 +0000 Subject: [PATCH 26/26] Fix protocol mismatch when connecting to existing relay - Prioritize detected protocol over user preference when connecting to existing servers - Use isNowRunning.isSecure instead of useSecure in EADDRINUSE recovery path - This prevents ws:// connection attempts to wss:// servers and vice versa --- packages/relay/src/connection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/relay/src/connection.ts b/packages/relay/src/connection.ts index a351355f7..5a9249b9e 100644 --- a/packages/relay/src/connection.ts +++ b/packages/relay/src/connection.ts @@ -182,13 +182,13 @@ export const connectRelay = async ( relayPort, handler, token, - useSecure, + isNowRunning.isSecure, ); } }; if (isRelayServerRunning) { - const actualSecure = secure ?? detectedSecure; + const actualSecure = detectedSecure ?? secure; relayServer = await connectToExistingRelay( relayPort, handler,