Restore macOS direct LAN connections (#3071)
* fix(desktop): restore direct LAN connections Desktop 0.3.0 routed remote WebSockets through the Electron main process, causing previously working macOS LAN connections to fail with EHOSTUNREACH. Revert custom direct headers and restore the renderer WebSocket path used before the regression. * fix(app): replace host connections by identity Keep one connection per stable ID when TLS or password settings change. This preserves the current-main upsert invariant while the custom-header rollback removes the unrelated transport feature.
This commit is contained in:
@@ -26,10 +26,8 @@ import {
|
||||
import { createSkillsCommandHandlers, getSkillsController } from "../integrations/skills/index.js";
|
||||
import {
|
||||
openLocalTransportSession,
|
||||
openWebSocketTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
closeLocalTransportSession,
|
||||
type WebSocketTransportTarget,
|
||||
} from "./local-transport.js";
|
||||
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js";
|
||||
@@ -539,9 +537,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
const target = args as { transportType: "socket" | "pipe"; transportPath: string };
|
||||
return await openLocalTransportSession(target);
|
||||
},
|
||||
open_websocket_daemon_transport: async (args) => {
|
||||
return await openWebSocketTransportSession(args as unknown as WebSocketTransportTarget);
|
||||
},
|
||||
send_local_daemon_transport_message: async (args) => {
|
||||
await sendLocalTransportMessage(
|
||||
args as { sessionId: string; text?: string; binaryBase64?: string },
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const send = vi.fn();
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => [{ webContents: { send } }],
|
||||
},
|
||||
}));
|
||||
|
||||
import { closeAllTransportSessions, openWebSocketTransportSession } from "./local-transport";
|
||||
|
||||
afterEach(() => {
|
||||
closeAllTransportSessions();
|
||||
send.mockReset();
|
||||
});
|
||||
|
||||
describe("desktop WebSocket transport", () => {
|
||||
it("attaches custom headers when opening a remote daemon session", async () => {
|
||||
const server = createServer();
|
||||
const webSocketServer = new WebSocketServer({ server });
|
||||
const requestHeaders = new Promise<Record<string, string | string[] | undefined>>((resolve) => {
|
||||
webSocketServer.once("connection", (_socket, request) => resolve(request.headers));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected TCP test server address.");
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionId = await openWebSocketTransportSession({
|
||||
transportType: "websocket",
|
||||
url: `ws://127.0.0.1:${address.port}/ws`,
|
||||
headers: { "X-Tenant": "acme" },
|
||||
});
|
||||
|
||||
expect((await requestHeaders)["x-tenant"]).toBe("acme");
|
||||
expect(sessionId).toMatch(/^local-session-/);
|
||||
} finally {
|
||||
closeAllTransportSessions();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
webSocketServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-WebSocket URLs", () => {
|
||||
expect(() =>
|
||||
openWebSocketTransportSession({
|
||||
transportType: "websocket",
|
||||
url: "https://daemon.example/ws",
|
||||
}),
|
||||
).toThrow("Unsupported WebSocket transport URL");
|
||||
});
|
||||
|
||||
it("rejects malformed custom headers before opening a session", () => {
|
||||
expect(() =>
|
||||
openWebSocketTransportSession({
|
||||
transportType: "websocket",
|
||||
url: "ws://daemon.example/ws",
|
||||
headers: { "Bad Header": "value" },
|
||||
}),
|
||||
).toThrow("Invalid WebSocket transport header");
|
||||
|
||||
expect(() =>
|
||||
openWebSocketTransportSession({
|
||||
transportType: "websocket",
|
||||
url: "ws://daemon.example/ws",
|
||||
headers: { "X-Tenant": "acme\r\nX-Injected: true" },
|
||||
}),
|
||||
).toThrow("Invalid WebSocket transport header");
|
||||
});
|
||||
});
|
||||
@@ -6,13 +6,6 @@ interface TransportTarget {
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface WebSocketTransportTarget {
|
||||
transportType: "websocket";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
interface TransportEventPayload {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
@@ -30,7 +23,6 @@ interface Session {
|
||||
}
|
||||
|
||||
const WS_ENDPOINT_PATH = "/ws";
|
||||
const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
let nextSessionId = 0;
|
||||
const sessions = new Map<string, Session>();
|
||||
@@ -60,28 +52,6 @@ function describeTransportTarget(target: TransportTarget): string {
|
||||
return target.transportType === "pipe" ? "local daemon pipe" : "local daemon socket";
|
||||
}
|
||||
|
||||
function normalizeWebSocketTarget(target: WebSocketTransportTarget): WebSocketTransportTarget {
|
||||
const parsed = new URL(target.url);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
throw new Error(`Unsupported WebSocket transport URL: ${target.url}`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(target.headers ?? {})) {
|
||||
if (!HTTP_HEADER_NAME_PATTERN.test(name) || typeof value !== "string" || /[\r\n]/.test(value)) {
|
||||
throw new Error("Invalid WebSocket transport header.");
|
||||
}
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
transportType: "websocket",
|
||||
url: parsed.toString(),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(target.protocols?.length ? { protocols: [...target.protocols] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeTransportMessage(input: { text?: string; binaryBase64?: string }): string | Buffer {
|
||||
if (typeof input.text === "string") {
|
||||
return input.text;
|
||||
@@ -95,28 +65,11 @@ function decodeTransportMessage(input: { text?: string; binaryBase64?: string })
|
||||
}
|
||||
|
||||
export function openLocalTransportSession(target: TransportTarget): Promise<string> {
|
||||
return openTransportSession(buildLocalWebSocketUrl(target), describeTransportTarget(target));
|
||||
}
|
||||
|
||||
export function openWebSocketTransportSession(target: WebSocketTransportTarget): Promise<string> {
|
||||
const normalized = normalizeWebSocketTarget(target);
|
||||
return openTransportSession(normalized.url, "remote daemon", {
|
||||
headers: normalized.headers,
|
||||
protocols: normalized.protocols,
|
||||
});
|
||||
}
|
||||
|
||||
function openTransportSession(
|
||||
url: string,
|
||||
targetDescription: string,
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
): Promise<string> {
|
||||
const sessionId = `local-session-${++nextSessionId}`;
|
||||
const url = buildLocalWebSocketUrl(target);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = options?.protocols
|
||||
? new WebSocket(url, options.protocols, { headers: options.headers })
|
||||
: new WebSocket(url, { headers: options?.headers });
|
||||
const ws = new WebSocket(url);
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ws,
|
||||
@@ -168,7 +121,9 @@ function openTransportSession(
|
||||
sessions.delete(sessionId);
|
||||
|
||||
if (!openSettled) {
|
||||
finalizeOpenFailure(`${targetDescription} closed before the session became ready.`);
|
||||
finalizeOpenFailure(
|
||||
`${describeTransportTarget(target)} closed before the session became ready.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,7 +139,9 @@ function openTransportSession(
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
if (!openSettled) {
|
||||
finalizeOpenFailure(`Failed to connect to ${targetDescription}: ${err.message}`);
|
||||
finalizeOpenFailure(
|
||||
`Failed to connect to ${describeTransportTarget(target)}: ${err.message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user