fix: avoid false offline results in Electron instance probes (#3415)

* fix: avoid false offline results in Electron instance probes

Use Chromium networking with one abortable deadline per direct-host attempt. Retry unreachable selector probes once while preserving identity, auth and compatibility checks, and clean up response bodies on every exit.

Add regression coverage and document the native probe invariants.

* fix: declare Electron probe's zod dependency

Declare the existing workspace zod version as an Electron runtime dependency so probe imports do not rely on root hoisting. Update only the matching lockfile workspace entry, without changing resolved versions or parsing behavior.
This commit is contained in:
Jordi Ibáñez
2026-09-09 17:44:38 +03:00
committed by GitHub
parent 03697190ff
commit 70e04fc15f
9 changed files with 501 additions and 118 deletions
+1
View File
@@ -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",
+25
View File
@@ -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
@@ -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);
});
+134
View File
@@ -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);
}
};
@@ -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']);
});
+10
View File
@@ -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);
};
@@ -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');
});
+18 -117
View File
@@ -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({
+2 -1
View File
@@ -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",