diff --git a/bun.lock b/bun.lock index dc5e09e9..5f6b8cba 100644 --- a/bun.lock +++ b/bun.lock @@ -102,6 +102,7 @@ "electron-context-menu": "^4.1.2", "electron-log": "^5.4.3", "electron-updater": "^6.8.3", + "zod": "^4.3.6", }, "devDependencies": { "@electron/rebuild": "^4.2.0", diff --git a/packages/electron/README.md b/packages/electron/README.md index e9eabfb4..3c53f510 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -19,6 +19,8 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE | File | Purpose | |------|---------| | `main.mjs` | Electron main process, app lifecycle, windows, menus, deep links, native IPC handlers, updates, local server startup | +| `electron-host-probe.mjs` | Chromium direct-host probes, identity checks, attempt deadlines, and response cleanup | +| `host-probe-policy.mjs` | Selector fast attempt and unreachable-only retry policy | | `startup-url-selection.mjs` | Pure bundled/HMR startup probe and loopback connection-limit policy | | `preload.mjs` | Safe bridge from the rendered UI to Electron IPC | | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | @@ -33,6 +35,29 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE ## Development +### Direct-host probe invariants + +After app readiness, direct-host probes use Chromium `net.fetch`, not Node fetch. +Each attempt shares one deadline across optional `/health` identity verification, +`/api/version`, and `/auth/session`, including JSON body reads. The fast attempt +has a 2-second budget. The selector retries once with a 10-second budget only +after Unreachable. Reported latency is the final attempt's application-probe +duration, excluding an earlier failed attempt. It is not raw network ping. + +Probes never follow redirects. A redirected identity check returns Wrong Service +before any bearer-bearing request. An explicit server ID mismatch also stops the +probe. Electron 43 reports a manual redirect as a rejected fetch rather than a +3xx response; the identity gate handles both forms. Identity requests carry +neither the client token nor custom headers; +version and session requests use sanitized custom headers and the client bearer +token. Older servers without identity metadata remain supported. HTTP 401 and +403 mean authentication is required, not that the instance is offline. + +Every exit aborts the attempt's requests and cancels unused response bodies before +clearing the deadline timer. This includes early HTTP classifications and a +successful session response whose body is not needed. TLS verification remains +enabled. These rules do not change relay probing or the preload/IPC contract. + From the repo root: ```bash diff --git a/packages/electron/electron-host-probe-server.test.mjs b/packages/electron/electron-host-probe-server.test.mjs new file mode 100644 index 00000000..e16f3e91 --- /dev/null +++ b/packages/electron/electron-host-probe-server.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import test from 'node:test'; +import { probeElectronHostWithDeadline } from './electron-host-probe.mjs'; + +const version = { status: 'ok', compatibility: { capabilities: ['api.runtime-url.v1'], apiVersion: 1, minClientApiVersion: 1 } }; + +const serve = async (t, handler) => { + const server = createServer(handler); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => { server.closeAllConnections(); server.close(); }); + return `http://127.0.0.1:${server.address().port}`; +}; +const probe = (url, options = {}) => probeElectronHostWithDeadline({ + url, timeoutMs: 150, chromiumFetch: fetch, isReady: () => true, ...options, +}); + +test('redirected identity cannot authorize the candidate or receive credentials', async (t) => { + let targetCalls = 0; + let credentialCalls = 0; + const target = await serve(t, (_req, res) => { targetCalls++; res.end(JSON.stringify({ serverId: 'expected' })); }); + const candidate = await serve(t, (req, res) => { + if (req.headers.authorization) credentialCalls++; + res.writeHead(302, { Location: `${target}/health` }); + res.end(); + }); + const result = await probe(candidate, { expectedServerId: 'expected', clientToken: 'fixture-only' }); + assert.equal(result.status, 'wrong-service'); + assert.equal(targetCalls, 0); + assert.equal(credentialCalls, 0); +}); + +for (const endpoint of ['/health', '/api/version']) { + test(`deadline aborts a stalled ${endpoint} body`, async (t) => { + let closed; + const bodyClosed = new Promise((resolve) => { closed = resolve; }); + const url = await serve(t, (req, res) => { + if (req.url === endpoint) { + res.on('close', closed); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.write('{'); + } else res.end(JSON.stringify({ serverId: 'expected' })); + }); + const result = await probe(url, { expectedServerId: endpoint === '/health' ? 'expected' : '' }); + assert.equal(result.status, 'unreachable'); + await bodyClosed; + }); +} + +for (const status of [200, 401, 403, 500]) { + test(`disposes unused session body after ${status} headers`, async (t) => { + let closed; + const bodyClosed = new Promise((resolve) => { closed = resolve; }); + const url = await serve(t, (req, res) => { + if (req.url === '/api/version') return res.end(JSON.stringify(version)); + res.on('close', closed); + res.writeHead(status); + res.write('unused'); + }); + const result = await probe(url, { timeoutMs: 1000 }); + assert.equal(result.status, status === 200 ? 'ok' : status === 500 ? 'unreachable' : 'auth'); + await bodyClosed; + }); +} + +test('readiness false starts no requests', async () => { + let calls = 0; + const result = await probe('https://instance.example', { + isReady: () => false, + chromiumFetch: () => { calls++; throw new Error('must not run'); }, + }); + assert.equal(result.status, 'unreachable'); + assert.equal(calls, 0); +}); diff --git a/packages/electron/electron-host-probe.mjs b/packages/electron/electron-host-probe.mjs new file mode 100644 index 00000000..3b6a035d --- /dev/null +++ b/packages/electron/electron-host-probe.mjs @@ -0,0 +1,134 @@ +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; +import { z } from 'zod'; + +const optionalIdentity = z.string().catch('').transform((value) => value.trim()); +const versionEnvelope = z.object({ + status: z.literal('ok'), + // Arrays historically classify as incompatible rather than wrong-service. + compatibility: z.union([z.looseObject({}), z.array(z.unknown())]), +}); + +const buildProbeUrl = (url, pathname) => { + try { + const parsed = new URL(url); + parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}${pathname}`; + return parsed.toString(); + } catch { + return null; + } +}; + +const classifyVersionPayload = (payload) => { + const parsed = versionEnvelope.safeParse(payload); + if (!parsed.success) { + return 'wrong-service'; + } + const { compatibility } = parsed.data; + if (!Array.isArray(compatibility.capabilities) || !compatibility.capabilities.includes('api.runtime-url.v1')) { + return 'incompatible'; + } + if (compatibility.apiVersion !== 1 || compatibility.minClientApiVersion > 1) { + return 'update-recommended'; + } + return 'ok'; +}; + +export const probeElectronHostWithDeadline = async ({ + url, + timeoutMs, + clientToken = '', + requestHeaders = {}, + expectedServerId = '', + chromiumFetch, + isReady, + now = Date.now, + scheduleTimeout = setTimeout, + cancelTimeout = clearTimeout, +}) => { + const started = now(); + const result = (status) => ({ status, latencyMs: now() - started }); + if (!isReady()) return result('unreachable'); + + const versionUrl = buildProbeUrl(url, '/api/version'); + const sessionUrl = buildProbeUrl(url, '/auth/session'); + if (!versionUrl || !sessionUrl) throw new Error('Invalid URL'); + + const controller = new AbortController(); + let rejectDeadline; + const deadline = new Promise((_, reject) => { + rejectDeadline = reject; + }); + const timer = scheduleTimeout(() => { + controller.abort(); + rejectDeadline(new Error('Host probe deadline exceeded')); + }, timeoutMs); + + const responses = new Set(); + const discardBody = async (response) => { + if (response.body && !response.body.locked) await response.body.cancel().catch(() => {}); + }; + const fetchProbe = async (requestUrl, headers) => { + controller.signal.throwIfAborted(); + const response = await chromiumFetch(requestUrl, { + headers, + signal: controller.signal, + redirect: 'manual', + }); + if (controller.signal.aborted) { + await discardBody(response); + controller.signal.throwIfAborted(); + } + responses.add(response); + return response; + }; + + const run = async () => { + const expectedIdentity = optionalIdentity.parse(expectedServerId); + if (expectedIdentity) { + const healthUrl = buildProbeUrl(url, '/health'); + if (healthUrl) { + try { + const response = await fetchProbe(healthUrl, { Accept: 'application/json' }); + // A redirected identity belongs to another candidate, even if its ID matches. + if (response.status >= 300 && response.status < 400 || response.redirected) return result('wrong-service'); + if (response.ok) { + const payload = await response.json().catch(() => null); + const reported = optionalIdentity.parse(payload?.serverId); + if (reported && reported !== expectedIdentity) return result('wrong-service'); + } + } catch (error) { + if (controller.signal.aborted) throw error; + // Electron 43 net.fetch rejects manual redirects instead of returning a 3xx response. + if (error instanceof Error && error.message === 'Redirect was cancelled') return result('wrong-service'); + // Identity is optional on older servers; the authenticated request remains authoritative. + } + } + } + + if (controller.signal.aborted) throw new Error('Host probe deadline exceeded'); + const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' }; + const token = optionalIdentity.parse(clientToken); + if (token) headers.Authorization = `Bearer ${token}`; + + const versionResponse = await fetchProbe(versionUrl, headers); + if (versionResponse.status === 401 || versionResponse.status === 403) return result('auth'); + if (!versionResponse.ok) return result('unreachable'); + const versionStatus = classifyVersionPayload(await versionResponse.json().catch(() => null)); + if (versionStatus !== 'ok') return result(versionStatus); + + const sessionResponse = await fetchProbe(sessionUrl, headers); + if (sessionResponse.status === 401 || sessionResponse.status === 403) return result('auth'); + if (!sessionResponse.ok) return result('unreachable'); + return result('ok'); + }; + + try { + return await Promise.race([run(), deadline]); + } catch { + return result('unreachable'); + } finally { + controller.abort(); + await Promise.allSettled([...responses].map(discardBody)); + cancelTimeout(timer); + } +}; diff --git a/packages/electron/electron-host-probe.test.mjs b/packages/electron/electron-host-probe.test.mjs new file mode 100644 index 00000000..ab959e40 --- /dev/null +++ b/packages/electron/electron-host-probe.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { probeElectronHostWithDeadline } from './electron-host-probe.mjs'; + +const response = (status, payload = null) => ({ + status, + ok: status >= 200 && status < 300, + json: async () => payload, +}); + +const compatibleVersion = { + status: 'ok', + compatibility: { + capabilities: ['api.runtime-url.v1'], + apiVersion: 1, + minClientApiVersion: 1, + }, +}; + +const baseProbe = (overrides = {}) => probeElectronHostWithDeadline({ + url: 'https://instance.example', + timeoutMs: 2_000, + chromiumFetch: async (url) => url.endsWith('/api/version') + ? response(200, compatibleVersion) + : response(200, {}), + isReady: () => true, + ...overrides, +}); + +test('uses the Chromium transport as the authoritative ready-state transport', async () => { + const calls = []; + const result = await baseProbe({ + chromiumFetch: async (url) => { + calls.push(url); + return url.endsWith('/api/version') ? response(200, compatibleVersion) : response(200, {}); + }, + }); + + assert.equal(result.status, 'ok'); + assert.deepEqual(calls.map((url) => new URL(url).pathname), ['/api/version', '/auth/session']); +}); + +test('shares one absolute deadline across version and session requests', async () => { + let expire; + let scheduled = 0; + let clock = 0; + const signals = []; + const resultPromise = baseProbe({ + now: () => clock, + scheduleTimeout: (callback) => { + scheduled += 1; + expire = callback; + return 1; + }, + cancelTimeout: () => {}, + chromiumFetch: async (url, options) => { + signals.push(options.signal); + if (url.endsWith('/api/version')) return response(200, compatibleVersion); + return new Promise((_, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')))); + }, + }); + + await Promise.resolve(); + await Promise.resolve(); + clock = 2_000; + expire(); + const result = await resultPromise; + assert.equal(scheduled, 1); + assert.equal(new Set(signals).size, 1); + assert.deepEqual(result, { status: 'unreachable', latencyMs: 2_000 }); +}); + +test('checks unauthenticated identity before sending sanitized bearer headers', async () => { + const calls = []; + const result = await baseProbe({ + expectedServerId: 'server-a', + clientToken: 'secret-token', + requestHeaders: { 'X-Instance': 'remote', Authorization: 'attacker' }, + chromiumFetch: async (url, options) => { + calls.push({ path: new URL(url).pathname, headers: options.headers }); + if (url.endsWith('/health')) return response(200, { serverId: 'server-a' }); + if (url.endsWith('/api/version')) return response(200, compatibleVersion); + return response(200, {}); + }, + }); + + assert.equal(result.status, 'ok'); + assert.deepEqual(calls.map((call) => call.path), ['/health', '/api/version', '/auth/session']); + assert.equal(calls[0].headers.Authorization, undefined); + assert.equal(calls[1].headers.Authorization, 'Bearer secret-token'); + assert.equal(calls[1].headers['X-Instance'], 'remote'); +}); + +for (const [name, versionResponse, expected] of [ + ['401 auth', response(401), 'auth'], + ['403 auth', response(403), 'auth'], + ['wrong service', response(200, {}), 'wrong-service'], + ['null payload', response(200, null), 'wrong-service'], + ['string compatibility', response(200, { status: 'ok', compatibility: 'yes' }), 'wrong-service'], + ['boolean compatibility', response(200, { status: 'ok', compatibility: true }), 'wrong-service'], + ['numeric compatibility', response(200, { status: 'ok', compatibility: 1 }), 'wrong-service'], + ['array compatibility', response(200, { status: 'ok', compatibility: [] }), 'incompatible'], + ['missing capability', response(200, { ...compatibleVersion, compatibility: { ...compatibleVersion.compatibility, capabilities: [] } }), 'incompatible'], + ['newer API', response(200, { ...compatibleVersion, compatibility: { ...compatibleVersion.compatibility, apiVersion: 2 } }), 'update-recommended'], + ['newer minimum client', response(200, { ...compatibleVersion, compatibility: { ...compatibleVersion.compatibility, minClientApiVersion: 2 } }), 'update-recommended'], +]) { + test(`preserves authoritative ${name} classification`, async () => { + const result = await baseProbe({ chromiumFetch: async () => versionResponse }); + assert.equal(result.status, expected); + }); +} + +test('rejects an explicit identity mismatch before bearer-bearing requests', async () => { + let calls = 0; + const result = await baseProbe({ + expectedServerId: 'server-a', + clientToken: 'secret-token', + chromiumFetch: async () => { + calls += 1; + return response(200, { serverId: 'server-b' }); + }, + }); + assert.equal(result.status, 'wrong-service'); + assert.equal(calls, 1); +}); + +test('rejects Electron manual-redirect errors before bearer-bearing requests', async () => { + let calls = 0; + const result = await baseProbe({ + expectedServerId: 'expected', + clientToken: 'fixture-only', + chromiumFetch: async (_url, options) => { + calls++; + assert.equal(options.redirect, 'manual'); + assert.equal(options.headers.Authorization, undefined); + throw new Error('Redirect was cancelled'); + }, + }); + assert.equal(result.status, 'wrong-service'); + assert.equal(calls, 1); +}); + +for (const serverId of [undefined, null, 123, true, {}, [], '', ' ', ' server-a ']) { + test(`preserves optional health identity parsing for ${JSON.stringify(serverId)}`, async () => { + const result = await baseProbe({ + expectedServerId: ' server-a ', + chromiumFetch: async (url) => url.endsWith('/health') + ? response(200, { serverId }) + : url.endsWith('/api/version') ? response(200, compatibleVersion) : response(200), + }); + assert.equal(result.status, 'ok'); + }); +} + +test('malformed version JSON remains wrong-service', async () => { + const result = await baseProbe({ chromiumFetch: async () => ({ + ...response(200), json: async () => { throw new SyntaxError('invalid fixture JSON'); }, + }) }); + assert.equal(result.status, 'wrong-service'); +}); + +test('non-string expected identity and token retain upstream ignore semantics', async () => { + const calls = []; + const result = await baseProbe({ + expectedServerId: 123, + clientToken: 123, + chromiumFetch: async (url, options) => { + calls.push(new URL(url).pathname); + assert.equal(options.headers.Authorization, undefined); + return url.endsWith('/api/version') ? response(200, compatibleVersion) : response(200); + }, + }); + assert.equal(result.status, 'ok'); + assert.deepEqual(calls, ['/api/version', '/auth/session']); +}); + +test('aborts requests and cancels unused bodies before clearing the timer', async () => { + const events = []; + const result = await baseProbe({ + scheduleTimeout: () => 1, + cancelTimeout: () => { events.push('timer-cleared'); }, + chromiumFetch: async (_url, { signal }) => { + signal.addEventListener('abort', () => events.push('aborted')); + return { + ...response(403), + body: { locked: false, cancel: async () => { events.push('body-cancelled'); } }, + }; + }, + }); + assert.equal(result.status, 'auth'); + assert.deepEqual(events, ['aborted', 'body-cancelled', 'timer-cleared']); +}); diff --git a/packages/electron/host-probe-policy.mjs b/packages/electron/host-probe-policy.mjs new file mode 100644 index 00000000..ee01026a --- /dev/null +++ b/packages/electron/host-probe-policy.mjs @@ -0,0 +1,10 @@ +export const FAST_HOST_PROBE_TIMEOUT_MS = 2_000; +export const RETRY_HOST_PROBE_TIMEOUT_MS = 10_000; + +export const probeDirectHostWithRetry = async (probe) => { + const fastResult = await probe(FAST_HOST_PROBE_TIMEOUT_MS); + if (fastResult.status !== 'unreachable') { + return fastResult; + } + return probe(RETRY_HOST_PROBE_TIMEOUT_MS); +}; diff --git a/packages/electron/host-probe-policy.test.mjs b/packages/electron/host-probe-policy.test.mjs new file mode 100644 index 00000000..25af5c80 --- /dev/null +++ b/packages/electron/host-probe-policy.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + FAST_HOST_PROBE_TIMEOUT_MS, + RETRY_HOST_PROBE_TIMEOUT_MS, + probeDirectHostWithRetry, +} from './host-probe-policy.mjs'; + +test('retries a fast unreachable direct-host probe with the slow timeout', async () => { + const timeouts = []; + const result = await probeDirectHostWithRetry(async (timeoutMs) => { + timeouts.push(timeoutMs); + return timeoutMs === FAST_HOST_PROBE_TIMEOUT_MS + ? { status: 'unreachable', latencyMs: timeoutMs } + : { status: 'ok', latencyMs: 2_500 }; + }); + + assert.deepEqual(timeouts, [FAST_HOST_PROBE_TIMEOUT_MS, RETRY_HOST_PROBE_TIMEOUT_MS]); + assert.deepEqual(result, { status: 'ok', latencyMs: 2_500 }); +}); + +for (const status of ['ok', 'auth', 'wrong-service', 'incompatible', 'update-recommended']) { + test(`does not retry an authoritative ${status} result`, async () => { + const timeouts = []; + const result = await probeDirectHostWithRetry(async (timeoutMs) => { + timeouts.push(timeoutMs); + return { status, latencyMs: 12 }; + }); + + assert.deepEqual(timeouts, [FAST_HOST_PROBE_TIMEOUT_MS]); + assert.equal(result.status, status); + }); +} + +test('stops after one unreachable retry', async () => { + const timeouts = []; + const result = await probeDirectHostWithRetry(async (timeoutMs) => { + timeouts.push(timeoutMs); + return { status: 'unreachable', latencyMs: timeoutMs }; + }); + assert.deepEqual(timeouts, [2_000, 10_000]); + assert.equal(result.status, 'unreachable'); +}); diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index fa027314..36792964 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -16,6 +16,8 @@ import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs'; import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; +import { probeDirectHostWithRetry } from './host-probe-policy.mjs'; +import { probeElectronHostWithDeadline } from './electron-host-probe.mjs'; import { assertUpdaterCapability } from './updater-capability.mjs'; import { checkForDesktopUpdate } from './updater-check.mjs'; import { resolveUpdaterChannel } from './updater-channel.mjs'; @@ -935,123 +937,16 @@ const buildHealthUrl = (url) => { } }; -const buildVersionUrl = (url) => { - try { - const parsed = new URL(url); - parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/api/version`; - return parsed.toString(); - } catch { - return null; - } -}; - -const buildSessionStatusUrl = (url) => { - try { - const parsed = new URL(url); - parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/auth/session`; - return parsed.toString(); - } catch { - return null; - } -}; - -const classifyVersionPayload = (payload) => { - const compatibility = payload?.compatibility; - if (!payload || payload.status !== 'ok' || !compatibility || typeof compatibility !== 'object') { - return 'wrong-service'; - } - - if (!Array.isArray(compatibility.capabilities) || !compatibility.capabilities.includes('api.runtime-url.v1')) { - return 'incompatible'; - } - - if (compatibility.apiVersion !== 1 || compatibility.minClientApiVersion > 1) { - return 'update-recommended'; - } - - return 'ok'; -}; - -const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => { - const timeoutSignal = AbortSignal.timeout(timeoutMs); - try { - return await fetch(versionUrl, { signal: timeoutSignal, headers }); - } catch (error) { - if (timeoutSignal.aborted) { - throw error; - } - return await Promise.race([ - electronNet.fetch(versionUrl, { headers }), - new Promise((_, reject) => setTimeout(() => reject(error), timeoutMs)), - ]); - } -}; - const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}, expectedServerId = '') => { - const versionUrl = buildVersionUrl(url); - const sessionStatusUrl = buildSessionStatusUrl(url); - if (!versionUrl || !sessionStatusUrl) { - throw new Error('Invalid URL'); - } - - const started = Date.now(); - - // Identity gate for learned/untrusted addresses: verify the UNAUTHENTICATED - // /health identity before the token-carrying version fetch, so the bearer - // token is never sent to a re-assigned address that now belongs to a - // different machine. Older servers omit serverId from /health; only an - // explicit mismatch rejects. - if (typeof expectedServerId === 'string' && expectedServerId.trim()) { - const healthUrl = buildHealthUrl(url); - if (healthUrl) { - try { - const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'application/json' } }); - if (response.ok) { - const payload = await response.json().catch(() => null); - const reported = typeof payload?.serverId === 'string' ? payload.serverId.trim() : ''; - if (reported && reported !== expectedServerId.trim()) { - return { status: 'wrong-service', latencyMs: Date.now() - started }; - } - } - } catch { - // Unreachable/timeout surfaces in the version fetch below. - } - } - } - - try { - const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' }; - const token = typeof clientToken === 'string' ? clientToken.trim() : ''; - if (token) { - headers.Authorization = `Bearer ${token}`; - } - const response = await fetchVersionPayload(versionUrl, { headers, timeoutMs }); - const status = response.status; - if (status === 401 || status === 403) { - return { status: 'auth', latencyMs: Date.now() - started }; - } - if (status < 200 || status >= 300) { - return { status: 'unreachable', latencyMs: Date.now() - started }; - } - const payload = await response.json().catch(() => null); - const versionStatus = classifyVersionPayload(payload); - if (versionStatus !== 'ok') { - return { status: versionStatus, latencyMs: Date.now() - started }; - } - const sessionResponse = await fetchVersionPayload(sessionStatusUrl, { headers, timeoutMs }); - if (sessionResponse.status === 401 || sessionResponse.status === 403) { - return { status: 'auth', latencyMs: Date.now() - started }; - } - if (!sessionResponse.ok) { - return { status: 'unreachable', latencyMs: Date.now() - started }; - } - return { - status: versionStatus, - latencyMs: Date.now() - started, - }; - } catch { - return { status: 'unreachable', latencyMs: Date.now() - started }; - } + return probeElectronHostWithDeadline({ + url, + timeoutMs, + clientToken, + requestHeaders, + expectedServerId, + chromiumFetch: (requestUrl, options) => electronNet.fetch(requestUrl, options), + isReady: () => app.isReady(), + }); }; const resolveStoredClientTokenForUrl = (targetUrl, config = readDesktopHostsConfig()) => { @@ -4491,7 +4386,13 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return getOrCreateDesktopInstallId(); case 'desktop_host_probe': - return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}, String(args.expectedServerId || '')); + return probeDirectHostWithRetry((timeoutMs) => probeHostWithTimeout( + String(args.url || ''), + timeoutMs, + String(args.clientToken || ''), + args.requestHeaders || {}, + String(args.expectedServerId || ''), + )); case 'desktop_remote_password_login': return loginRemoteAndIssueClientToken({ diff --git a/packages/electron/package.json b/packages/electron/package.json index 19607a11..2e02739d 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -10,7 +10,8 @@ "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", "electron-log": "^5.4.3", - "electron-updater": "^6.8.3" + "electron-updater": "^6.8.3", + "zod": "^4.3.6" }, "devDependencies": { "@electron/rebuild": "^4.2.0",