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:
Mohamed Boudra
2026-08-09 15:32:31 +08:00
committed by GitHub
parent 7761d122d8
commit fd9cc5d693
36 changed files with 153 additions and 1131 deletions
@@ -114,16 +114,6 @@ test.describe("Settings sidebar navigation", () => {
await expectDirectHostUriHidden(page); await expectDirectHostUriHidden(page);
}); });
test("browser web hides direct connection custom headers", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
await expect(page.getByTestId("direct-header-add")).toHaveCount(0);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => { test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
@@ -17,6 +17,49 @@ interface PairingHostInput {
endpoint: string; endpoint: string;
} }
export async function prepareLocalPairingHost(
page: Page,
daemon: IsolatedHostDaemon | OutdatedDaemon,
additionalHosts: PairingHostInput[] = [],
): Promise<void> {
await page.addInitScript((localServerId) => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localServerId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
opener: {
openUrl: async (url: string) => {
localStorage.setItem("@paseo:e2e-opened-url", url);
},
},
};
}, daemon.serverId);
await preparePairingHost(page, daemon, additionalHosts);
}
export async function preparePairingHost( export async function preparePairingHost(
page: Page, page: Page,
daemon: IsolatedHostDaemon | OutdatedDaemon, daemon: IsolatedHostDaemon | OutdatedDaemon,
+20 -201
View File
@@ -1,10 +1,9 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useMemo, useReducer, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { getIsElectron, isNative } from "@/constants/platform"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { import {
@@ -13,11 +12,6 @@ import {
serializeConnectionUriForStorage, serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints"; } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import {
prepareConnectionHeaders,
type ConnectionHeaderDraft,
type ConnectionHeaderValidationIssue,
} from "@/utils/connection-headers";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -129,30 +123,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
advancedContent: {
gap: theme.spacing[4],
},
headerSection: {
gap: theme.spacing[2],
},
headerTitleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
headerList: {
gap: theme.spacing[2],
},
headerRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerInput: {
flex: 1,
minWidth: 0,
},
actions: { actions: {
flexDirection: "row", flexDirection: "row",
gap: theme.spacing[3], gap: theme.spacing[3],
@@ -211,94 +181,6 @@ function prepareDirectConnection(
}; };
} }
function headerIssueMessage(
issue: ConnectionHeaderValidationIssue,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (issue.type === "missingName") return t("pairing.direct.headers.errors.missingName");
if (issue.type === "invalidName") {
return t("pairing.direct.headers.errors.invalidName", { name: issue.name });
}
if (issue.type === "invalidValue") {
return t("pairing.direct.headers.errors.invalidValue", { name: issue.name });
}
return t("pairing.direct.headers.errors.duplicateName", { name: issue.name });
}
interface ConnectionHeaderRowProps {
draft: ConnectionHeaderDraft;
index: number;
disabled: boolean;
placeholderTextColor: string;
iconColor: string;
onUpdate: (id: number, field: "name" | "value", value: string) => void;
onRemove: (id: number) => void;
}
function ConnectionHeaderRow({
draft,
index,
disabled,
placeholderTextColor,
iconColor,
onUpdate,
onRemove,
}: ConnectionHeaderRowProps) {
const { t } = useTranslation();
const handleNameChange = useCallback(
(value: string) => onUpdate(draft.id, "name", value),
[draft.id, onUpdate],
);
const handleValueChange = useCallback(
(value: string) => onUpdate(draft.id, "value", value),
[draft.id, onUpdate],
);
const handleRemove = useCallback(() => onRemove(draft.id), [draft.id, onRemove]);
return (
<View style={styles.headerRow}>
<AdaptiveTextInput
testID={`direct-header-name-${index}`}
nativeID={`direct-header-name-${draft.id}`}
accessibilityLabel={t("pairing.direct.headers.name")}
initialValue={draft.name}
value={draft.name}
onChangeText={handleNameChange}
placeholder={t("pairing.direct.headers.name")}
placeholderTextColor={placeholderTextColor}
style={[styles.input, styles.headerInput]}
autoCapitalize="none"
autoCorrect={false}
editable={!disabled}
/>
<AdaptiveTextInput
testID={`direct-header-value-${index}`}
nativeID={`direct-header-value-${draft.id}`}
accessibilityLabel={t("pairing.direct.headers.value")}
initialValue={draft.value}
value={draft.value}
onChangeText={handleValueChange}
placeholder={t("pairing.direct.headers.value")}
placeholderTextColor={placeholderTextColor}
style={[styles.input, styles.headerInput]}
autoCapitalize="none"
autoCorrect={false}
editable={!disabled}
/>
<Pressable
style={styles.iconButton}
onPress={handleRemove}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={t("pairing.direct.headers.remove")}
testID={`direct-header-remove-${index}`}
>
<X size={18} color={iconColor} />
</Pressable>
</View>
);
}
function draftFromConnectionUri(uri: string): DirectConnectionDraft { function draftFromConnectionUri(uri: string): DirectConnectionDraft {
const parsed = parseConnectionUri(uri); const parsed = parseConnectionUri(uri);
return { return {
@@ -423,8 +305,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState(""); const [advancedUri, setAdvancedUri] = useState("");
const [headerDrafts, setHeaderDrafts] = useState<ConnectionHeaderDraft[]>([]);
const nextHeaderId = useRef(1);
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => { const clearInput = useCallback(() => {
@@ -435,8 +315,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false); setIsPasswordVisible(false);
setIsAdvancedOpen(false); setIsAdvancedOpen(false);
setAdvancedUri(""); setAdvancedUri("");
setHeaderDrafts([]);
nextHeaderId.current = 1;
bumpInputResetKey(); bumpInputResetKey();
}, []); }, []);
@@ -491,18 +369,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
if (isSaving) return; if (isSaving) return;
let connection: PreparedDirectConnection; let connection: PreparedDirectConnection;
let headers: Record<string, string> | undefined;
try { try {
connection = prepareDirectConnection( connection = prepareDirectConnection(
{ host, port, useTls, password }, { host, port, useTls, password },
directConnectionLabels, directConnectionLabels,
); );
const preparedHeaders = prepareConnectionHeaders(headerDrafts);
if (preparedHeaders.issue) {
setErrorMessage(headerIssueMessage(preparedHeaders.issue, t));
return;
}
headers = preparedHeaders.headers;
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : directConnectionLabels.invalidConnection; error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
@@ -518,7 +389,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
endpoint: connection.endpoint, endpoint: connection.endpoint,
useTls: connection.useTls, useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}), ...(connection.password ? { password: connection.password } : {}),
...(headers ? { headers } : {}),
}); });
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
@@ -555,7 +425,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
daemons, daemons,
directConnectionLabels, directConnectionLabels,
handleClose, handleClose,
headerDrafts,
host, host,
isMobile, isMobile,
isSaving, isSaving,
@@ -584,22 +453,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible((current) => !current); setIsPasswordVisible((current) => !current);
}, []); }, []);
const handleAddHeader = useCallback(() => {
const id = nextHeaderId.current;
nextHeaderId.current += 1;
setHeaderDrafts((current) => [...current, { id, name: "", value: "" }]);
}, []);
const handleUpdateHeader = useCallback((id: number, field: "name" | "value", value: string) => {
setHeaderDrafts((current) =>
current.map((draft) => (draft.id === id ? { ...draft, [field]: value } : draft)),
);
}, []);
const handleRemoveHeader = useCallback((id: number) => {
setHeaderDrafts((current) => current.filter((draft) => draft.id !== id));
}, []);
const handleToggleAdvanced = useCallback(() => { const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) { if (!isAdvancedOpen) {
try { try {
@@ -756,58 +609,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text> <Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable> </Pressable>
{isAdvancedOpen ? ( {isAdvancedOpen ? (
<View style={styles.advancedContent}> <AdaptiveTextInput
<AdaptiveTextInput testID="direct-host-uri-input"
testID="direct-host-uri-input" nativeID="direct-host-uri-input"
nativeID="direct-host-uri-input" accessibilityLabel={t("pairing.direct.fields.connectionUri")}
accessibilityLabel={t("pairing.direct.fields.connectionUri")} initialValue={advancedUri}
initialValue={advancedUri} resetKey={`direct-host-uri-${inputResetKey}`}
resetKey={`direct-host-uri-${inputResetKey}`} value={advancedUri}
value={advancedUri} onChangeText={setAdvancedUri}
onChangeText={setAdvancedUri} placeholder="tcp://localhost:6767?ssl=true"
placeholder="tcp://localhost:6767?ssl=true" placeholderTextColor={theme.colors.foregroundMuted}
placeholderTextColor={theme.colors.foregroundMuted} style={styles.input}
style={styles.input} autoCapitalize="none"
autoCapitalize="none" autoCorrect={false}
autoCorrect={false} keyboardType="url"
keyboardType="url" editable={!isSaving}
editable={!isSaving} returnKeyType="done"
returnKeyType="done" onSubmitEditing={handleToggleAdvanced}
onSubmitEditing={handleToggleAdvanced} />
/>
{isNative || getIsElectron() ? (
<View style={styles.headerSection}>
<View style={styles.headerTitleRow}>
<Text style={styles.label}>{t("pairing.direct.headers.title")}</Text>
<Button
variant="ghost"
size="sm"
leftIcon={Plus}
onPress={handleAddHeader}
disabled={isSaving}
testID="direct-header-add"
>
{t("pairing.direct.headers.add")}
</Button>
</View>
<View style={styles.headerList}>
{headerDrafts.map((draft, index) => (
<ConnectionHeaderRow
key={draft.id}
draft={draft}
index={index}
disabled={isSaving}
placeholderTextColor={theme.colors.foregroundMuted}
iconColor={theme.colors.foregroundMuted}
onUpdate={handleUpdateHeader}
onRemove={handleRemoveHeader}
/>
))}
</View>
</View>
) : null}
</View>
) : null} ) : null}
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View> </View>
@@ -1,8 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "./desktop-daemon-transport";
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc"; import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"; const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
@@ -46,25 +43,4 @@ describe("desktop-daemon-transport", () => {
expect(rpc.closedSessions).toEqual(["local-session-2"]); expect(rpc.closedSessions).toEqual(["local-session-2"]);
expect(cleanup).toHaveBeenCalledTimes(1); expect(cleanup).toHaveBeenCalledTimes(1);
}); });
it("forwards remote WebSocket headers and protocols through the desktop bridge", () => {
const rpc = createFakeLocalDaemonTransportRpc();
const transportFactory = createDesktopWebSocketTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
transportFactory!({
url: "wss://daemon.example/ws",
headers: { "X-Tenant": "acme", Authorization: "Bearer secret" },
protocols: ["paseo.bearer.secret"],
});
expect(rpc.openCalls).toEqual([
{
transportType: "websocket",
url: "wss://daemon.example/ws",
headers: { "X-Tenant": "acme", Authorization: "Bearer secret" },
protocols: ["paseo.bearer.secret"],
},
]);
});
}); });
@@ -2,13 +2,11 @@ import type {
DaemonTransport, DaemonTransport,
DaemonTransportFactory, DaemonTransportFactory,
} from "@getpaseo/client/internal/daemon-client"; } from "@getpaseo/client/internal/daemon-client";
import type { DesktopDaemonTransportTarget, LocalTransportTarget } from "./desktop-daemon"; import type { LocalTransportTarget } from "./desktop-daemon";
import { import {
defaultLocalDaemonTransportRpc, defaultLocalDaemonTransportRpc,
defaultWebSocketDaemonTransportRpc,
type LocalDaemonTransportRpc, type LocalDaemonTransportRpc,
} from "./local-daemon-transport-rpc"; } from "./local-daemon-transport-rpc";
import { isElectronRuntime } from "@/desktop/host";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:"; const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
@@ -52,12 +50,11 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
}; };
} }
function createDesktopDaemonTransportFactory( export function createDesktopLocalDaemonTransportFactory(
resolveTarget: (options: Parameters<DaemonTransportFactory>[0]) => DesktopDaemonTransportTarget, rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
rpc: LocalDaemonTransportRpc, ): DaemonTransportFactory | null {
): DaemonTransportFactory { return ({ url }) => {
return (options) => { const target = parseLocalDaemonTransportUrl(url);
const target = resolveTarget(options);
let sessionId: string | null = null; let sessionId: string | null = null;
let unlisten: (() => void) | null = null; let unlisten: (() => void) | null = null;
let disposed = false; let disposed = false;
@@ -190,26 +187,3 @@ function createDesktopDaemonTransportFactory(
return transport; return transport;
}; };
} }
export function createDesktopLocalDaemonTransportFactory(
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
): DaemonTransportFactory | null {
return createDesktopDaemonTransportFactory(({ url }) => parseLocalDaemonTransportUrl(url), rpc);
}
export function createDesktopWebSocketTransportFactory(
rpc?: LocalDaemonTransportRpc,
): DaemonTransportFactory | null {
if (!rpc && !isElectronRuntime()) {
return null;
}
return createDesktopDaemonTransportFactory(
({ url, headers, protocols }) => ({
transportType: "websocket" as const,
url,
...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
...(protocols && protocols.length > 0 ? { protocols } : {}),
}),
rpc ?? defaultWebSocketDaemonTransportRpc,
);
}
@@ -46,16 +46,6 @@ export interface LocalTransportTarget {
transportPath: string; transportPath: string;
} }
export interface WebSocketTransportTarget {
[key: string]: unknown;
transportType: "websocket";
url: string;
headers?: Record<string, string>;
protocols?: string[];
}
export type DesktopDaemonTransportTarget = LocalTransportTarget | WebSocketTransportTarget;
interface LocalTransportEventPayload { interface LocalTransportEventPayload {
sessionId: string; sessionId: string;
kind: "open" | "message" | "close" | "error"; kind: "open" | "message" | "close" | "error";
@@ -203,16 +193,6 @@ export async function openLocalTransportSession(target: LocalTransportTarget): P
return raw; return raw;
} }
export async function openWebSocketTransportSession(
target: WebSocketTransportTarget,
): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("open_websocket_daemon_transport", target);
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("Unexpected WebSocket transport session response.");
}
return raw;
}
export async function sendLocalTransportMessage(input: { export async function sendLocalTransportMessage(input: {
sessionId: string; sessionId: string;
text?: string; text?: string;
@@ -2,10 +2,8 @@ import {
closeLocalTransportSession, closeLocalTransportSession,
listenToLocalTransportEvents, listenToLocalTransportEvents,
openLocalTransportSession, openLocalTransportSession,
openWebSocketTransportSession,
sendLocalTransportMessage, sendLocalTransportMessage,
type DesktopDaemonTransportTarget, type LocalTransportTarget,
type WebSocketTransportTarget,
} from "./desktop-daemon"; } from "./desktop-daemon";
export interface LocalDaemonTransportEvent { export interface LocalDaemonTransportEvent {
@@ -19,7 +17,7 @@ export interface LocalDaemonTransportEvent {
} }
export interface LocalDaemonTransportRpc { export interface LocalDaemonTransportRpc {
openSession(target: DesktopDaemonTransportTarget): Promise<string>; openSession(target: LocalTransportTarget): Promise<string>;
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>; listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>; sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
closeSession(sessionId: string): Promise<void>; closeSession(sessionId: string): Promise<void>;
@@ -31,10 +29,3 @@ export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = {
sendMessage: sendLocalTransportMessage, sendMessage: sendLocalTransportMessage,
closeSession: closeLocalTransportSession, closeSession: closeLocalTransportSession,
}; };
export const defaultWebSocketDaemonTransportRpc: LocalDaemonTransportRpc = {
openSession: (target) => openWebSocketTransportSession(target as WebSocketTransportTarget),
listenToEvents: listenToLocalTransportEvents,
sendMessage: sendLocalTransportMessage,
closeSession: closeLocalTransportSession,
};
@@ -1,4 +1,4 @@
import type { DesktopDaemonTransportTarget } from "./desktop-daemon"; import type { LocalTransportTarget } from "./desktop-daemon";
import type { import type {
LocalDaemonTransportEvent, LocalDaemonTransportEvent,
LocalDaemonTransportRpc, LocalDaemonTransportRpc,
@@ -11,7 +11,7 @@ export interface RecordedSend {
} }
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc { export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
readonly openCalls: DesktopDaemonTransportTarget[]; readonly openCalls: LocalTransportTarget[];
readonly recordedSends: RecordedSend[]; readonly recordedSends: RecordedSend[];
readonly closedSessions: string[]; readonly closedSessions: string[];
resolveOpen(sessionId: string): void; resolveOpen(sessionId: string): void;
@@ -22,7 +22,7 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
} }
export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc { export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc {
const openCalls: DesktopDaemonTransportTarget[] = []; const openCalls: LocalTransportTarget[] = [];
const recordedSends: RecordedSend[] = []; const recordedSends: RecordedSend[] = [];
const closedSessions: string[] = []; const closedSessions: string[] = [];
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null; let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
-13
View File
@@ -1426,19 +1426,6 @@ export const ar: TranslationResources = {
show: "عرض متقدم", show: "عرض متقدم",
hide: "إخفاء المتقدمة", hide: "إخفاء المتقدمة",
}, },
headers: {
title: "رؤوس مخصصة",
add: "إضافة رأس",
name: "الاسم",
value: "القيمة",
remove: "إزالة الرأس",
errors: {
missingName: "أدخل اسمًا لكل رأس",
invalidName: "{{name}} ليس اسم رأس صالحًا",
invalidValue: "يحتوي {{name}} على فاصل أسطر غير صالح",
duplicateName: "تم إدخال {{name}} أكثر من مرة",
},
},
passwordVisibility: { passwordVisibility: {
show: "إظهار كلمة المرور", show: "إظهار كلمة المرور",
hide: "إخفاء كلمة المرور", hide: "إخفاء كلمة المرور",
-13
View File
@@ -1437,19 +1437,6 @@ export const en = {
show: "Show advanced", show: "Show advanced",
hide: "Hide advanced", hide: "Hide advanced",
}, },
headers: {
title: "Custom headers",
add: "Add header",
name: "Name",
value: "Value",
remove: "Remove header",
errors: {
missingName: "Enter a name for each header",
invalidName: "{{name}} is not a valid header name",
invalidValue: "{{name}} contains an invalid line break",
duplicateName: "{{name}} is entered more than once",
},
},
passwordVisibility: { passwordVisibility: {
show: "Show password", show: "Show password",
hide: "Hide password", hide: "Hide password",
-13
View File
@@ -1467,19 +1467,6 @@ export const es: TranslationResources = {
show: "Mostrar avanzado", show: "Mostrar avanzado",
hide: "Ocultar avanzado", hide: "Ocultar avanzado",
}, },
headers: {
title: "Encabezados personalizados",
add: "Añadir encabezado",
name: "Nombre",
value: "Valor",
remove: "Eliminar encabezado",
errors: {
missingName: "Introduce un nombre para cada encabezado",
invalidName: "{{name}} no es un nombre de encabezado válido",
invalidValue: "{{name}} contiene un salto de línea no válido",
duplicateName: "{{name}} se ha introducido más de una vez",
},
},
passwordVisibility: { passwordVisibility: {
show: "Mostrar contraseña", show: "Mostrar contraseña",
hide: "Ocultar contraseña", hide: "Ocultar contraseña",
-13
View File
@@ -1471,19 +1471,6 @@ export const fr: TranslationResources = {
show: "Afficher avancé", show: "Afficher avancé",
hide: "Masquer avancé", hide: "Masquer avancé",
}, },
headers: {
title: "En-têtes personnalisés",
add: "Ajouter un en-tête",
name: "Nom",
value: "Valeur",
remove: "Supprimer l'en-tête",
errors: {
missingName: "Saisissez un nom pour chaque en-tête",
invalidName: "{{name}} n'est pas un nom d'en-tête valide",
invalidValue: "{{name}} contient un saut de ligne non valide",
duplicateName: "{{name}} est saisi plusieurs fois",
},
},
passwordVisibility: { passwordVisibility: {
show: "Afficher le mot de passe", show: "Afficher le mot de passe",
hide: "Masquer le mot de passe", hide: "Masquer le mot de passe",
-13
View File
@@ -1440,19 +1440,6 @@ export const ja: TranslationResources = {
show: "詳細設定を表示", show: "詳細設定を表示",
hide: "詳細設定を非表示", hide: "詳細設定を非表示",
}, },
headers: {
title: "カスタムヘッダー",
add: "ヘッダーを追加",
name: "名前",
value: "値",
remove: "ヘッダーを削除",
errors: {
missingName: "各ヘッダーの名前を入力してください",
invalidName: "{{name}} は有効なヘッダー名ではありません",
invalidValue: "{{name}} に無効な改行が含まれています",
duplicateName: "{{name}} が複数回入力されています",
},
},
passwordVisibility: { passwordVisibility: {
show: "パスワードを表示", show: "パスワードを表示",
hide: "パスワードを非表示", hide: "パスワードを非表示",
-13
View File
@@ -1436,19 +1436,6 @@ export const ko: TranslationResources = {
show: "고급 표시", show: "고급 표시",
hide: "고급 숨기기", hide: "고급 숨기기",
}, },
headers: {
title: "사용자 지정 헤더",
add: "헤더 추가",
name: "이름",
value: "값",
remove: "헤더 삭제",
errors: {
missingName: "각 헤더의 이름을 입력하세요",
invalidName: "{{name}}은(는) 올바른 헤더 이름이 아닙니다",
invalidValue: "{{name}}에 잘못된 줄바꿈이 있습니다",
duplicateName: "{{name}}이(가) 두 번 이상 입력되었습니다",
},
},
passwordVisibility: { passwordVisibility: {
show: "비밀번호 표시", show: "비밀번호 표시",
hide: "비밀번호 숨기기", hide: "비밀번호 숨기기",
-13
View File
@@ -1453,19 +1453,6 @@ export const ptBR: TranslationResources = {
show: "Mostrar avançado", show: "Mostrar avançado",
hide: "Ocultar avançado", hide: "Ocultar avançado",
}, },
headers: {
title: "Cabeçalhos personalizados",
add: "Adicionar cabeçalho",
name: "Nome",
value: "Valor",
remove: "Remover cabeçalho",
errors: {
missingName: "Digite um nome para cada cabeçalho",
invalidName: "{{name}} não é um nome de cabeçalho válido",
invalidValue: "{{name}} contém uma quebra de linha inválida",
duplicateName: "{{name}} foi inserido mais de uma vez",
},
},
passwordVisibility: { passwordVisibility: {
show: "Mostrar senha", show: "Mostrar senha",
hide: "Ocultar senha", hide: "Ocultar senha",
-13
View File
@@ -1458,19 +1458,6 @@ export const ru: TranslationResources = {
show: "Показать расширенные", show: "Показать расширенные",
hide: "Скрыть расширенные", hide: "Скрыть расширенные",
}, },
headers: {
title: "Пользовательские заголовки",
add: "Добавить заголовок",
name: "Имя",
value: "Значение",
remove: "Удалить заголовок",
errors: {
missingName: "Введите имя для каждого заголовка",
invalidName: "{{name}} — недопустимое имя заголовка",
invalidValue: "{{name}} содержит недопустимый перенос строки",
duplicateName: "{{name}} введён несколько раз",
},
},
passwordVisibility: { passwordVisibility: {
show: "Показать пароль", show: "Показать пароль",
hide: "Скрыть пароль", hide: "Скрыть пароль",
-13
View File
@@ -1409,19 +1409,6 @@ export const zhCN: TranslationResources = {
show: "显示高级选项", show: "显示高级选项",
hide: "隐藏高级选项", hide: "隐藏高级选项",
}, },
headers: {
title: "自定义请求头",
add: "添加请求头",
name: "名称",
value: "值",
remove: "删除请求头",
errors: {
missingName: "请为每个请求头输入名称",
invalidName: "{{name}} 不是有效的请求头名称",
invalidValue: "{{name}} 包含无效换行符",
duplicateName: "{{name}} 输入了多次",
},
},
passwordVisibility: { passwordVisibility: {
show: "显示密码", show: "显示密码",
hide: "隐藏密码", hide: "隐藏密码",
@@ -3042,7 +3042,7 @@ describe("HostRuntimeStore", () => {
} }
}); });
it("upsertDirectConnection stores SSL, password, and custom header settings", async () => { it("upsertDirectConnection stores SSL and password settings", async () => {
const store = new HostRuntimeStore({ const store = new HostRuntimeStore({
deps: { deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient, createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
@@ -3060,7 +3060,6 @@ describe("HostRuntimeStore", () => {
endpoint: "example.paseo.test:7443", endpoint: "example.paseo.test:7443",
useTls: true, useTls: true,
password: "shared-secret", password: "shared-secret",
headers: { "X-Tenant": "acme" },
label: "tls host", label: "tls host",
}); });
@@ -3072,7 +3071,6 @@ describe("HostRuntimeStore", () => {
endpoint: "example.paseo.test:7443", endpoint: "example.paseo.test:7443",
useTls: true, useTls: true,
password: "shared-secret", password: "shared-secret",
headers: { "X-Tenant": "acme" },
}, },
]); ]);
+1 -12
View File
@@ -36,7 +36,6 @@ import {
import { import {
buildLocalDaemonTransportUrl, buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory, createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport"; } from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
@@ -479,10 +478,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
return { return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => { createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const localTransportFactory = createDesktopLocalDaemonTransportFactory(); const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const webSocketTransportFactory = createDesktopWebSocketTransportFactory(); const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() };
const webSocketConfig = webSocketTransportFactory
? { transportFactory: webSocketTransportFactory }
: { webSocketFactory: createAppWebSocketFactory() };
const base = { const base = {
suppressSendErrors: true, suppressSendErrors: true,
clientId, clientId,
@@ -510,7 +506,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
useTls: connection.useTls ?? false, useTls: connection.useTls ?? false,
}), }),
...(connection.password ? { password: connection.password } : {}), ...(connection.password ? { password: connection.password } : {}),
...(connection.headers ? { headers: connection.headers } : {}),
}); });
} }
return new DaemonClient({ return new DaemonClient({
@@ -1651,7 +1646,6 @@ export class HostRuntimeStore {
endpoint: string; endpoint: string;
useTls?: boolean; useTls?: boolean;
password?: string; password?: string;
headers?: Record<string, string>;
label?: string; label?: string;
existingClient?: DaemonClient; existingClient?: DaemonClient;
}): Promise<HostProfile> { }): Promise<HostProfile> {
@@ -1666,7 +1660,6 @@ export class HostRuntimeStore {
endpoint, endpoint,
useTls: input.useTls ?? false, useTls: input.useTls ?? false,
...(password ? { password } : {}), ...(password ? { password } : {}),
...(input.headers ? { headers: input.headers } : {}),
}, },
existingClient: input.existingClient, existingClient: input.existingClient,
}); });
@@ -1708,7 +1701,6 @@ export class HostRuntimeStore {
endpoint: string; endpoint: string;
useTls?: boolean; useTls?: boolean;
password?: string; password?: string;
headers?: Record<string, string>;
label?: string; label?: string;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const endpoint = normalizeHostPort(input.endpoint); const endpoint = normalizeHostPort(input.endpoint);
@@ -1721,7 +1713,6 @@ export class HostRuntimeStore {
endpoint, endpoint,
useTls: input.useTls ?? false, useTls: input.useTls ?? false,
...(password ? { password } : {}), ...(password ? { password } : {}),
...(input.headers ? { headers: input.headers } : {}),
}, },
}); });
} }
@@ -2463,14 +2454,12 @@ export interface HostMutations {
endpoint: string; endpoint: string;
useTls?: boolean; useTls?: boolean;
password?: string; password?: string;
headers?: Record<string, string>;
label?: string; label?: string;
}) => Promise<HostProfile>; }) => Promise<HostProfile>;
probeAndUpsertDirectConnection: (input: { probeAndUpsertDirectConnection: (input: {
endpoint: string; endpoint: string;
useTls?: boolean; useTls?: boolean;
password?: string; password?: string;
headers?: Record<string, string>;
label?: string; label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
upsertRelayConnection: (input: { upsertRelayConnection: (input: {
+12 -81
View File
@@ -75,49 +75,6 @@ describe("normalizeStoredHostProfile", () => {
expect(profile?.connections[0]).not.toHaveProperty("password"); expect(profile?.connections[0]).not.toHaveProperty("password");
}); });
it("preserves custom headers on stored direct TCP connections", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_headers",
connections: [
{
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
headers: { "X-Tenant": "acme" },
},
],
});
expect(profile?.connections[0]).toEqual({
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
useTls: false,
headers: { "X-Tenant": "acme" },
});
});
it("keeps the connection but discards malformed stored custom headers", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_malformed_headers",
connections: [
{
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
headers: { "X-Tenant": 42 },
},
],
});
expect(profile?.connections[0]).toEqual({
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
useTls: false,
});
});
it("preserves legacy relay ids when TLS is absent", () => { it("preserves legacy relay ids when TLS is absent", () => {
const profile = normalizeStoredHostProfile({ const profile = normalizeStoredHostProfile({
serverId: "srv_relay", serverId: "srv_relay",
@@ -219,59 +176,33 @@ describe("upsertHostConnectionInProfiles", () => {
expect(profile.appearance).toEqual({ color: "amber", badgeDisplay: "hidden" }); expect(profile.appearance).toEqual({ color: "amber", badgeDisplay: "hidden" });
}); });
it("deduplicates matching custom headers regardless of key order", () => { it("replaces a direct connection when its settings change", () => {
const firstConnection: HostConnection = {
id: "direct:example.test:6767",
type: "directTcp",
endpoint: "example.test:6767",
headers: { "X-One": "1", "X-Two": "2" },
};
const existing = {
...makeHost("srv_known"),
connections: [firstConnection],
};
const [profile] = upsertHostConnectionInProfiles({
profiles: [existing],
serverId: "srv_known",
connection: {
...firstConnection,
headers: { "X-Two": "2", "X-One": "1" },
},
});
expect(profile.connections).toHaveLength(1);
});
it("replaces a direct connection when its custom headers change", () => {
const existingConnection: HostConnection = { const existingConnection: HostConnection = {
id: "direct:example.test:6767", id: "direct:example.test:6767",
type: "directTcp", type: "directTcp",
endpoint: "example.test:6767", endpoint: "example.test:6767",
headers: { "X-Tenant": "old" }, useTls: false,
password: "old-secret",
}; };
const existing = { const existing: HostProfile = {
...makeHost("srv_known"), ...makeHost("srv_known"),
connections: [existingConnection], connections: [existingConnection],
preferredConnectionId: existingConnection.id, preferredConnectionId: existingConnection.id,
}; };
const replacement: HostConnection = {
...existingConnection,
useTls: true,
password: "new-secret",
};
const [profile] = upsertHostConnectionInProfiles({ const [profile] = upsertHostConnectionInProfiles({
profiles: [existing], profiles: [existing],
serverId: "srv_known", serverId: "srv_known",
connection: { connection: replacement,
...existingConnection,
headers: { "X-Tenant": "new" },
},
}); });
expect(profile.connections).toEqual([ expect(profile.connections).toEqual([replacement]);
{ expect(profile.preferredConnectionId).toBe(replacement.id);
...existingConnection,
headers: { "X-Tenant": "new" },
},
]);
expect(profile.preferredConnectionId).toBe(existingConnection.id);
}); });
}); });
+1 -21
View File
@@ -11,7 +11,6 @@ import {
defaultHostAppearance, defaultHostAppearance,
normalizeStoredHostAppearance, normalizeStoredHostAppearance,
} from "@/hosts/appearance"; } from "@/hosts/appearance";
import { normalizeConnectionHeadersRecord } from "@/utils/connection-headers";
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
@@ -105,18 +104,6 @@ export function resolveActiveHostServerId(params: {
); );
} }
function stringRecordEquals(
left: Record<string, string> | undefined,
right: Record<string, string> | undefined,
): boolean {
const leftEntries = Object.entries(left ?? {});
const rightRecord = right ?? {};
return (
leftEntries.length === Object.keys(rightRecord).length &&
leftEntries.every(([key, value]) => rightRecord[key] === value)
);
}
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean { function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
if (left.type !== right.type || left.id !== right.id) { if (left.type !== right.type || left.id !== right.id) {
return false; return false;
@@ -126,8 +113,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
return ( return (
left.endpoint === right.endpoint && left.endpoint === right.endpoint &&
(left.useTls ?? false) === (right.useTls ?? false) && (left.useTls ?? false) === (right.useTls ?? false) &&
left.password === right.password && left.password === right.password
stringRecordEquals(left.headers, right.headers)
); );
} }
if (left.type === "directSocket" && right.type === "directSocket") { if (left.type === "directSocket" && right.type === "directSocket") {
@@ -315,11 +301,6 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isPlainRecord(value) ? value : undefined; return isPlainRecord(value) ? value : undefined;
} }
function normalizeStoredHeaders(value: unknown): { headers?: Record<string, string> } {
const headers = normalizeConnectionHeadersRecord(value);
return headers ? { headers } : {};
}
function normalizeStoredConnection(connection: unknown): HostConnection | null { function normalizeStoredConnection(connection: unknown): HostConnection | null {
const record = toObjectRecord(connection); const record = toObjectRecord(connection);
if (!record) { if (!record) {
@@ -337,7 +318,6 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
endpoint, endpoint,
useTls: record.useTls, useTls: record.useTls,
...(typeof record.password === "string" ? { password: record.password } : {}), ...(typeof record.password === "string" ? { password: record.password } : {}),
...normalizeStoredHeaders(record.headers),
}); });
} catch { } catch {
return null; return null;
@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeConnectionHeadersRecord, prepareConnectionHeaders } from "./connection-headers";
describe("prepareConnectionHeaders", () => {
it("trims and returns custom headers", () => {
expect(
prepareConnectionHeaders([
{ id: 1, name: " X-Tenant ", value: " acme " },
{ id: 2, name: "X-Empty", value: "" },
]),
).toEqual({ headers: { "X-Tenant": "acme", "X-Empty": "" } });
});
it("ignores completely empty draft rows", () => {
expect(prepareConnectionHeaders([{ id: 1, name: "", value: "" }])).toEqual({});
});
it("requires a name when a value is entered", () => {
expect(prepareConnectionHeaders([{ id: 1, name: "", value: "acme" }])).toEqual({
issue: { type: "missingName" },
});
});
it("rejects invalid and duplicate names case-insensitively", () => {
expect(prepareConnectionHeaders([{ id: 1, name: "Bad Header", value: "value" }])).toEqual({
issue: { type: "invalidName", name: "Bad Header" },
});
expect(
prepareConnectionHeaders([
{ id: 1, name: "X-Tenant", value: "one" },
{ id: 2, name: "x-tenant", value: "two" },
]),
).toEqual({ issue: { type: "duplicateName", name: "x-tenant" } });
});
it("rejects line breaks in values", () => {
expect(prepareConnectionHeaders([{ id: 1, name: "X-Test", value: "one\ntwo" }])).toEqual({
issue: { type: "invalidValue", name: "X-Test" },
});
});
});
describe("normalizeConnectionHeadersRecord", () => {
it("accepts a valid persisted string record", () => {
expect(normalizeConnectionHeadersRecord({ "X-Tenant": "acme" })).toEqual({
"X-Tenant": "acme",
});
});
it("rejects malformed persisted header values", () => {
expect(normalizeConnectionHeadersRecord({ "X-Tenant": 42 })).toBeUndefined();
expect(
normalizeConnectionHeadersRecord({ "X-Tenant": "acme\r\nX-Injected: true" }),
).toBeUndefined();
});
});
@@ -1,65 +0,0 @@
export interface ConnectionHeaderDraft {
id: number;
name: string;
value: string;
}
export type ConnectionHeaderValidationIssue =
| { type: "missingName" }
| { type: "invalidName"; name: string }
| { type: "invalidValue"; name: string }
| { type: "duplicateName"; name: string };
const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function prepareConnectionHeaders(drafts: readonly ConnectionHeaderDraft[]): {
headers?: Record<string, string>;
issue?: ConnectionHeaderValidationIssue;
} {
const headers: Record<string, string> = {};
const normalizedNames = new Set<string>();
for (const draft of drafts) {
const name = draft.name.trim();
const value = draft.value.trim();
if (!name && !value) continue;
if (!name) return { issue: { type: "missingName" } };
if (!HTTP_HEADER_NAME_PATTERN.test(name)) {
return { issue: { type: "invalidName", name } };
}
if (/\r|\n/.test(value)) {
return { issue: { type: "invalidValue", name } };
}
const normalizedName = name.toLowerCase();
if (normalizedNames.has(normalizedName)) {
return { issue: { type: "duplicateName", name } };
}
normalizedNames.add(normalizedName);
headers[name] = value;
}
return Object.keys(headers).length > 0 ? { headers } : {};
}
export function normalizeConnectionHeadersRecord(
value: unknown,
): Record<string, string> | undefined {
if (!isPlainRecord(value)) {
return undefined;
}
const drafts: ConnectionHeaderDraft[] = [];
for (const [name, headerValue] of Object.entries(value)) {
if (typeof headerValue !== "string") {
return undefined;
}
drafts.push({ id: drafts.length, name, value: headerValue });
}
const result = prepareConnectionHeaders(drafts);
return result.issue ? undefined : result.headers;
}
@@ -101,6 +101,29 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.clientIdsRequested).toBe(2); expect(probe.clientIdsRequested).toBe(2);
}); });
it("keeps direct TCP probes on the renderer WebSocket", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const deps = {
...probe.deps,
createWebSocketTransportFactory: () => {
throw new Error("Direct TCP must not use the desktop WebSocket bridge");
},
};
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
undefined,
deps,
);
await result.client.close();
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
});
it("encodes the local socket target into the client config", async () => { it("encodes the local socket target into the client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon( const result = await connectToDaemon(
@@ -134,46 +157,6 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret"); expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
}); });
it("passes direct TCP custom headers into the initial probe client config", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
headers: { "X-Tenant": "acme" },
},
undefined,
probe.deps,
);
await result.client.close();
expect(probe.createdConfigs()[0]?.headers).toEqual({ "X-Tenant": "acme" });
});
it("uses the Electron WebSocket transport for the initial probe", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const transportFactory: NonNullable<DaemonClientConfig["transportFactory"]> = () => {
throw new Error("The fake probe client should not open the transport.");
};
const result = await connectToDaemon(
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
headers: { "X-Tenant": "acme" },
},
undefined,
{
...probe.deps,
createWebSocketTransportFactory: () => transportFactory,
},
);
await result.client.close();
expect(probe.createdConfigs()[0]?.transportFactory).toBe(transportFactory);
});
it("passes performance tracing into the connected client", async () => { it("passes performance tracing into the connected client", async () => {
const { connectToDaemon } = await import("./test-daemon-connection"); const { connectToDaemon } = await import("./test-daemon-connection");
const trace = { const trace = {
@@ -11,7 +11,6 @@ import {
import { import {
buildLocalDaemonTransportUrl, buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory, createDesktopLocalDaemonTransportFactory,
createDesktopWebSocketTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport"; } from "@/desktop/daemon/desktop-daemon-transport";
export interface DaemonProbeClient { export interface DaemonProbeClient {
@@ -30,7 +29,6 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
getClientId(): Promise<string>; getClientId(): Promise<string>;
resolveAppVersion(): string | null; resolveAppVersion(): string | null;
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null; createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
createWebSocketTransportFactory?(): DaemonClientConfig["transportFactory"] | null;
buildLocalTransportUrl(input: LocalTransportUrlInput): string; buildLocalTransportUrl(input: LocalTransportUrlInput): string;
createClient(config: DaemonClientConfig): TClient; createClient(config: DaemonClientConfig): TClient;
} }
@@ -39,17 +37,10 @@ const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonCl
getClientId: getOrCreateClientId, getClientId: getOrCreateClientId,
resolveAppVersion, resolveAppVersion,
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory, createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
createWebSocketTransportFactory: createDesktopWebSocketTransportFactory,
buildLocalTransportUrl: buildLocalDaemonTransportUrl, buildLocalTransportUrl: buildLocalDaemonTransportUrl,
createClient: (config) => new DaemonClient(config), createClient: (config) => new DaemonClient(config),
}; };
function resolveWebSocketTransportFactory(
deps: Pick<DaemonConnectionDependencies<DaemonProbeClient>, "createWebSocketTransportFactory">,
): DaemonClientConfig["transportFactory"] {
return deps.createWebSocketTransportFactory?.() ?? undefined;
}
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -112,11 +103,7 @@ export async function buildClientConfig(
}, },
deps: Pick< deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>, DaemonConnectionDependencies<DaemonProbeClient>,
| "getClientId" "getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
| "resolveAppVersion"
| "createLocalTransportFactory"
| "createWebSocketTransportFactory"
| "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies, > = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> { ): Promise<DaemonClientConfig> {
const clientId = await deps.getClientId(); const clientId = await deps.getClientId();
@@ -148,10 +135,8 @@ export async function buildClientConfig(
if (connection.type === "directTcp") { if (connection.type === "directTcp") {
return { return {
...base, ...base,
transportFactory: resolveWebSocketTransportFactory(deps),
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }), url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
...(connection.password ? { password: connection.password } : {}), ...(connection.password ? { password: connection.password } : {}),
...(connection.headers ? { headers: connection.headers } : {}),
}; };
} }
-27
View File
@@ -707,33 +707,6 @@ test("passes password as HTTP bearer header and WebSocket subprotocol", async ()
}); });
}); });
test("passes custom headers and keeps password authorization authoritative", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
password: "shared-secret",
headers: { "X-Tenant": "acme", Authorization: "Bearer ignored" },
logger,
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(transportFactory).toHaveBeenCalledWith({
url: "ws://test",
headers: { "X-Tenant": "acme", Authorization: "Bearer shared-secret" },
protocols: ["paseo.bearer.shared-secret"],
});
});
test("advertises client capabilities in hello", async () => { test("advertises client capabilities in hello", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
+1 -2
View File
@@ -301,7 +301,6 @@ export interface DaemonClientConfig {
runtimeGeneration?: number | null; runtimeGeneration?: number | null;
password?: string; password?: string;
authHeader?: string; authHeader?: string;
headers?: Record<string, string>;
suppressSendErrors?: boolean; suppressSendErrors?: boolean;
transportFactory?: DaemonTransportFactory; transportFactory?: DaemonTransportFactory;
webSocketFactory?: WebSocketFactory; webSocketFactory?: WebSocketFactory;
@@ -1246,7 +1245,7 @@ export class DaemonClient {
return; return;
} }
const headers: Record<string, string> = { ...this.config.headers }; const headers: Record<string, string> = {};
const password = normalizePassword(this.config.password); const password = normalizePassword(this.config.password);
if (password) { if (password) {
headers.Authorization = `Bearer ${password}`; headers.Authorization = `Bearer ${password}`;
-1
View File
@@ -57,7 +57,6 @@ export interface PaseoClientConfig {
runtimeGeneration?: number | null; runtimeGeneration?: number | null;
password?: string; password?: string;
authHeader?: string; authHeader?: string;
headers?: Record<string, string>;
suppressSendErrors?: boolean; suppressSendErrors?: boolean;
logger?: PaseoLogger; logger?: PaseoLogger;
connectTimeoutMs?: number; connectTimeoutMs?: number;
@@ -1,31 +0,0 @@
import { expect, test } from "../../app/e2e/support/fixtures";
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import {
openAddHostFlow,
selectHostConnectionType,
toggleHostAdvanced,
} from "../../app/e2e/support/helpers/settings";
import { installDesktopRuntime } from "./support/runtime";
test("Electron users can add and remove direct connection custom headers", async ({ page }) => {
await installDesktopRuntime(page, { serverId: getServerId() });
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
const addHeader = page.getByTestId("direct-header-add");
await expect(addHeader).toBeVisible();
await addHeader.click();
await page.getByTestId("direct-header-name-0").fill("X-Tenant");
await page.getByTestId("direct-header-value-0").fill("acme");
await expect(page.getByTestId("direct-header-name-0")).toHaveValue("X-Tenant");
await expect(page.getByTestId("direct-header-value-0")).toHaveValue("acme");
await page.getByTestId("direct-header-remove-0").click();
await expect(page.getByTestId("direct-header-name-0")).toHaveCount(0);
await expect(page.getByTestId("direct-header-value-0")).toHaveCount(0);
});
+1 -17
View File
@@ -1,5 +1,4 @@
import { expect, test } from "../../app/e2e/support/fixtures"; import { expect, test } from "../../app/e2e/support/fixtures";
import type { Page } from "@playwright/test";
import { import {
startIsolatedHostDaemon, startIsolatedHostDaemon,
type IsolatedHostDaemon, type IsolatedHostDaemon,
@@ -19,12 +18,11 @@ import {
openPairDeviceFromHome, openPairDeviceFromHome,
openRelaySecurityDocs, openRelaySecurityDocs,
observePairingOfferRequests, observePairingOfferRequests,
preparePairingHost, prepareLocalPairingHost,
reloadAndOpenPairDevice, reloadAndOpenPairDevice,
retryRelayAndExpectFailure, retryRelayAndExpectFailure,
switchPairDeviceToHost, switchPairDeviceToHost,
} from "../../app/e2e/support/helpers/pair-device"; } from "../../app/e2e/support/helpers/pair-device";
import { installDesktopRuntime } from "./support/runtime";
interface RelayConfigDaemonClient { interface RelayConfigDaemonClient {
close(): Promise<void>; close(): Promise<void>;
@@ -32,20 +30,6 @@ interface RelayConfigDaemonClient {
patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>; patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>;
} }
async function prepareLocalPairingHost(
page: Page,
daemon: Parameters<typeof preparePairingHost>[1],
additionalHosts: Parameters<typeof preparePairingHost>[2] = [],
): Promise<void> {
const endpoint = "port" in daemon ? `127.0.0.1:${daemon.port}` : daemon.endpoint;
await installDesktopRuntime(page, {
serverId: daemon.serverId,
daemonListen: endpoint,
manageBuiltInDaemon: false,
});
await preparePairingHost(page, daemon, additionalHosts);
}
test.describe("local device relay pairing", () => { test.describe("local device relay pairing", () => {
let relayOffDaemon: IsolatedHostDaemon; let relayOffDaemon: IsolatedHostDaemon;
let relayEnableDaemon: IsolatedHostDaemon; let relayEnableDaemon: IsolatedHostDaemon;
@@ -1,15 +1,24 @@
import { test, expect } from "../../app/e2e/support/fixtures"; import { test, expect } from "../../app/e2e/support/fixtures";
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import { installDesktopRuntime } from "./support/runtime";
test.describe("Settings sidebar scrolling", () => { test.describe("Settings sidebar scrolling", () => {
test.use({ viewport: { width: 900, height: 260 } }); test.use({ viewport: { width: 900, height: 260 } });
test("desktop drag region does not cover the scroll body", async ({ page }) => { test("desktop drag region does not cover the scroll body", async ({ page }) => {
await installDesktopRuntime(page, { await page.addInitScript(() => {
serverId: getServerId(), window.paseoDesktop = {
manageBuiltInDaemon: true, platform: "darwin",
events: { on: () => () => {} },
invoke: async (command: string) => {
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
};
}
return null;
},
};
}); });
await gotoAppShell(page); await gotoAppShell(page);
+15 -192
View File
@@ -139,123 +139,8 @@ export async function installDesktopRuntime(
let daemonRunning = true; let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null; let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0; let startCount = 0;
let nextTransportSessionId = 0;
const transportSessions = new Map<string, WebSocket>();
const desktopEventHandlers = new Map<string, Set<(payload: unknown) => void>>();
window.__desktopDaemonStartRequested = false; window.__desktopDaemonStartRequested = false;
function emitDesktopEvent(event: string, payload: unknown) {
for (const handler of desktopEventHandlers.get(event) ?? []) {
handler(payload);
}
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function parseWebSocketTransportTarget(args?: Record<string, unknown>): {
url: string;
protocols: string[];
} {
const url = typeof args?.url === "string" ? args.url : "";
const protocols = Array.isArray(args?.protocols)
? args.protocols.filter((protocol): protocol is string => typeof protocol === "string")
: [];
const headers =
args?.headers && typeof args.headers === "object"
? Object.entries(args.headers as Record<string, unknown>)
: [];
const passwordProtocol = protocols.find((protocol) => protocol.startsWith("paseo.bearer."));
const supportsHeaders =
headers.length === 0 ||
(headers.length === 1 &&
headers[0][0].toLowerCase() === "authorization" &&
typeof headers[0][1] === "string" &&
passwordProtocol !== undefined &&
headers[0][1] === `Bearer ${passwordProtocol.slice("paseo.bearer.".length)}`);
if (!url || !supportsHeaders) {
throw new Error("The browser-backed desktop E2E transport cannot attach custom headers.");
}
return { url, protocols };
}
async function openWebSocketTransport(args?: Record<string, unknown>): Promise<string> {
const target = parseWebSocketTransportTarget(args);
const sessionId = `e2e-websocket-session-${++nextTransportSessionId}`;
const socket = new WebSocket(target.url, target.protocols);
socket.binaryType = "arraybuffer";
transportSessions.set(sessionId, socket);
return await new Promise<string>((resolve, reject) => {
let opened = false;
socket.addEventListener("open", () => {
opened = true;
resolve(sessionId);
emitDesktopEvent("local-daemon-transport-event", { sessionId, kind: "open" });
});
socket.addEventListener("message", async (event) => {
if (typeof event.data === "string") {
emitDesktopEvent("local-daemon-transport-event", {
sessionId,
kind: "message",
text: event.data,
});
return;
}
const buffer =
event.data instanceof ArrayBuffer
? event.data
: await (event.data as Blob).arrayBuffer();
emitDesktopEvent("local-daemon-transport-event", {
sessionId,
kind: "message",
binaryBase64: bytesToBase64(new Uint8Array(buffer)),
});
});
socket.addEventListener("close", (event) => {
transportSessions.delete(sessionId);
if (!opened) {
reject(new Error("Desktop E2E WebSocket closed before opening."));
return;
}
emitDesktopEvent("local-daemon-transport-event", {
sessionId,
kind: "close",
code: event.code,
reason: event.reason,
});
});
socket.addEventListener("error", () => {
if (!opened) {
transportSessions.delete(sessionId);
reject(new Error("Desktop E2E WebSocket failed to open."));
return;
}
emitDesktopEvent("local-daemon-transport-event", {
sessionId,
kind: "error",
error: "Desktop E2E WebSocket transport error.",
});
});
});
}
function buildDaemonStatus() { function buildDaemonStatus() {
return { return {
serverId: cfg.serverId, serverId: cfg.serverId,
@@ -290,55 +175,6 @@ export async function installDesktopRuntime(
} }
} }
function patchDesktopSettings(args?: Record<string, unknown>) {
const daemon = args?.daemon;
if (
daemon !== null &&
typeof daemon === "object" &&
"manageBuiltInDaemon" in daemon &&
typeof daemon.manageBuiltInDaemon === "boolean"
) {
manageDaemon = daemon.manageBuiltInDaemon;
}
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
}
function sendWebSocketTransportMessage(args?: Record<string, unknown>): null {
const sessionId = typeof args?.sessionId === "string" ? args.sessionId : "";
const socket = transportSessions.get(sessionId);
if (!socket || socket.readyState !== WebSocket.OPEN) {
throw new Error(`Desktop E2E WebSocket session is not open: ${sessionId}`);
}
if (typeof args?.text === "string") {
socket.send(args.text);
return null;
}
if (typeof args?.binaryBase64 === "string") {
socket.send(base64ToBytes(args.binaryBase64));
return null;
}
throw new Error("Desktop E2E WebSocket send requires a payload.");
}
function closeWebSocketTransport(args?: Record<string, unknown>): null {
const sessionId = typeof args?.sessionId === "string" ? args.sessionId : "";
transportSessions.get(sessionId)?.close();
transportSessions.delete(sessionId);
return null;
}
const webSocketTransportCommandHandlers: Record<
string,
(args?: Record<string, unknown>) => unknown
> = {
open_websocket_daemon_transport: openWebSocketTransport,
send_local_daemon_transport_message: sendWebSocketTransportMessage,
close_local_daemon_transport: closeWebSocketTransport,
};
const desktopBridge: { const desktopBridge: {
platform: string; platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>; invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
@@ -347,10 +183,7 @@ export async function installDesktopRuntime(
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>; open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
}; };
getPendingOpenProject: () => Promise<string | null>; getPendingOpenProject: () => Promise<string | null>;
opener: { openUrl: (url: string) => Promise<void> }; events: { on: () => Promise<() => void> };
events: {
on: (event: string, handler: (payload: unknown) => void) => Promise<() => void>;
};
editor?: { editor?: {
listTargets: () => Promise<DesktopEditorTargetConfig[]>; listTargets: () => Promise<DesktopEditorTargetConfig[]>;
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>; openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
@@ -406,7 +239,19 @@ export async function installDesktopRuntime(
} }
if (command === "patch_desktop_settings") { if (command === "patch_desktop_settings") {
return patchDesktopSettings(args); const daemon = args?.daemon;
if (
daemon !== null &&
typeof daemon === "object" &&
"manageBuiltInDaemon" in daemon &&
typeof daemon.manageBuiltInDaemon === "boolean"
) {
manageDaemon = daemon.manageBuiltInDaemon;
}
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
};
} }
if (command === "stop_desktop_daemon") { if (command === "stop_desktop_daemon") {
@@ -419,11 +264,6 @@ export async function installDesktopRuntime(
return startDesktopDaemon(); return startDesktopDaemon();
} }
const transportCommandHandler = webSocketTransportCommandHandlers[command];
if (transportCommandHandler) {
return await transportCommandHandler(args);
}
return null; return null;
}, },
dialog: { dialog: {
@@ -440,24 +280,7 @@ export async function installDesktopRuntime(
}, },
}, },
getPendingOpenProject: async () => null, getPendingOpenProject: async () => null,
opener: { events: { on: async () => () => undefined },
openUrl: async (url) => {
localStorage.setItem("@paseo:e2e-opened-url", url);
},
},
events: {
on: async (event, handler) => {
const handlers = desktopEventHandlers.get(event) ?? new Set();
handlers.add(handler);
desktopEventHandlers.set(event, handlers);
return () => {
handlers.delete(handler);
if (handlers.size === 0) {
desktopEventHandlers.delete(event);
}
};
},
},
}; };
if (cfg.editorTargets) { if (cfg.editorTargets) {
@@ -26,10 +26,8 @@ import {
import { createSkillsCommandHandlers, getSkillsController } from "../integrations/skills/index.js"; import { createSkillsCommandHandlers, getSkillsController } from "../integrations/skills/index.js";
import { import {
openLocalTransportSession, openLocalTransportSession,
openWebSocketTransportSession,
sendLocalTransportMessage, sendLocalTransportMessage,
closeLocalTransportSession, closeLocalTransportSession,
type WebSocketTransportTarget,
} from "./local-transport.js"; } from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js"; import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.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 }; const target = args as { transportType: "socket" | "pipe"; transportPath: string };
return await openLocalTransportSession(target); 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) => { send_local_daemon_transport_message: async (args) => {
await sendLocalTransportMessage( await sendLocalTransportMessage(
args as { sessionId: string; text?: string; binaryBase64?: string }, 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");
});
});
+8 -51
View File
@@ -6,13 +6,6 @@ interface TransportTarget {
transportPath: string; transportPath: string;
} }
export interface WebSocketTransportTarget {
transportType: "websocket";
url: string;
headers?: Record<string, string>;
protocols?: string[];
}
interface TransportEventPayload { interface TransportEventPayload {
sessionId: string; sessionId: string;
kind: "open" | "message" | "close" | "error"; kind: "open" | "message" | "close" | "error";
@@ -30,7 +23,6 @@ interface Session {
} }
const WS_ENDPOINT_PATH = "/ws"; const WS_ENDPOINT_PATH = "/ws";
const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
let nextSessionId = 0; let nextSessionId = 0;
const sessions = new Map<string, Session>(); 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"; 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 { function decodeTransportMessage(input: { text?: string; binaryBase64?: string }): string | Buffer {
if (typeof input.text === "string") { if (typeof input.text === "string") {
return input.text; return input.text;
@@ -95,28 +65,11 @@ function decodeTransportMessage(input: { text?: string; binaryBase64?: string })
} }
export function openLocalTransportSession(target: TransportTarget): Promise<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 sessionId = `local-session-${++nextSessionId}`;
const url = buildLocalWebSocketUrl(target);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = options?.protocols const ws = new WebSocket(url);
? new WebSocket(url, options.protocols, { headers: options.headers })
: new WebSocket(url, { headers: options?.headers });
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ws, ws,
@@ -168,7 +121,9 @@ function openTransportSession(
sessions.delete(sessionId); sessions.delete(sessionId);
if (!openSettled) { if (!openSettled) {
finalizeOpenFailure(`${targetDescription} closed before the session became ready.`); finalizeOpenFailure(
`${describeTransportTarget(target)} closed before the session became ready.`,
);
return; return;
} }
@@ -184,7 +139,9 @@ function openTransportSession(
ws.on("error", (err: Error) => { ws.on("error", (err: Error) => {
if (!openSettled) { if (!openSettled) {
finalizeOpenFailure(`Failed to connect to ${targetDescription}: ${err.message}`); finalizeOpenFailure(
`Failed to connect to ${describeTransportTarget(target)}: ${err.message}`,
);
return; return;
} }
@@ -6,7 +6,6 @@ export const DirectTcpHostConnectionSchema = z.object({
endpoint: z.string(), endpoint: z.string(),
useTls: z.boolean().optional().default(false), useTls: z.boolean().optional().default(false),
password: z.string().optional(), password: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
}); });
export type DirectTcpHostConnection = z.input<typeof DirectTcpHostConnectionSchema>; export type DirectTcpHostConnection = z.input<typeof DirectTcpHostConnectionSchema>;