From fd9cc5d6932fadde3dabc83162199e41c7754a5a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 9 Aug 2026 09:32:31 +0200 Subject: [PATCH] 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. --- .../e2e/browser/settings-navigation.spec.ts | 10 - .../app/e2e/support/helpers/pair-device.ts | 43 ++++ .../app/src/components/add-host-modal.tsx | 221 ++---------------- .../daemon/desktop-daemon-transport.test.ts | 26 +-- .../daemon/desktop-daemon-transport.ts | 38 +-- .../app/src/desktop/daemon/desktop-daemon.ts | 20 -- .../daemon/local-daemon-transport-rpc.ts | 13 +- .../daemon/test-local-daemon-transport-rpc.ts | 6 +- packages/app/src/i18n/resources/ar.ts | 13 -- packages/app/src/i18n/resources/en.ts | 13 -- packages/app/src/i18n/resources/es.ts | 13 -- packages/app/src/i18n/resources/fr.ts | 13 -- packages/app/src/i18n/resources/ja.ts | 13 -- packages/app/src/i18n/resources/ko.ts | 13 -- packages/app/src/i18n/resources/pt-BR.ts | 13 -- packages/app/src/i18n/resources/ru.ts | 13 -- packages/app/src/i18n/resources/zh-CN.ts | 13 -- packages/app/src/runtime/host-runtime.test.ts | 4 +- packages/app/src/runtime/host-runtime.ts | 13 +- .../app/src/types/host-connection.test.ts | 93 +------- packages/app/src/types/host-connection.ts | 22 +- .../app/src/utils/connection-headers.test.ts | 56 ----- packages/app/src/utils/connection-headers.ts | 65 ------ .../src/utils/test-daemon-connection.test.ts | 63 ++--- .../app/src/utils/test-daemon-connection.ts | 17 +- packages/client/src/daemon-client.test.ts | 27 --- packages/client/src/daemon-client.ts | 3 +- packages/client/src/index.ts | 1 - .../e2e/direct-connection-headers.spec.ts | 31 --- .../desktop/e2e/pair-device-relay.spec.ts | 18 +- .../e2e/settings-sidebar-scroll.spec.ts | 19 +- packages/desktop/e2e/support/runtime.ts | 207 ++-------------- packages/desktop/src/daemon/daemon-manager.ts | 5 - .../src/daemon/local-transport.test.ts | 86 ------- .../desktop/src/daemon/local-transport.ts | 59 +---- .../protocol/src/host-connection-schema.ts | 1 - 36 files changed, 153 insertions(+), 1131 deletions(-) delete mode 100644 packages/app/src/utils/connection-headers.test.ts delete mode 100644 packages/app/src/utils/connection-headers.ts delete mode 100644 packages/desktop/e2e/direct-connection-headers.spec.ts delete mode 100644 packages/desktop/src/daemon/local-transport.test.ts diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index 3657f2293..5a3624f06 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -114,16 +114,6 @@ test.describe("Settings sidebar navigation", () => { 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 }) => { await gotoAppShell(page); await openSettings(page); diff --git a/packages/app/e2e/support/helpers/pair-device.ts b/packages/app/e2e/support/helpers/pair-device.ts index b1c15de82..a454cdf2d 100644 --- a/packages/app/e2e/support/helpers/pair-device.ts +++ b/packages/app/e2e/support/helpers/pair-device.ts @@ -17,6 +17,49 @@ interface PairingHostInput { endpoint: string; } +export async function prepareLocalPairingHost( + page: Page, + daemon: IsolatedHostDaemon | OutdatedDaemon, + additionalHosts: PairingHostInput[] = [], +): Promise { + 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( page: Page, daemon: IsolatedHostDaemon | OutdatedDaemon, diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index dc7ef43d6..6bd72e2ad 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -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 { Alert, Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { getIsElectron, isNative } from "@/constants/platform"; -import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native"; +import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { @@ -13,11 +12,6 @@ import { serializeConnectionUriForStorage, } from "@/utils/daemon-endpoints"; 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 { Button } from "@/components/ui/button"; @@ -129,30 +123,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, 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: { flexDirection: "row", gap: theme.spacing[3], @@ -211,94 +181,6 @@ function prepareDirectConnection( }; } -function headerIssueMessage( - issue: ConnectionHeaderValidationIssue, - t: ReturnType["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 ( - - - - - - - - ); -} - function draftFromConnectionUri(uri: string): DirectConnectionDraft { const parsed = parseConnectionUri(uri); return { @@ -423,8 +305,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [advancedUri, setAdvancedUri] = useState(""); - const [headerDrafts, setHeaderDrafts] = useState([]); - const nextHeaderId = useRef(1); const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const clearInput = useCallback(() => { @@ -435,8 +315,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod setIsPasswordVisible(false); setIsAdvancedOpen(false); setAdvancedUri(""); - setHeaderDrafts([]); - nextHeaderId.current = 1; bumpInputResetKey(); }, []); @@ -491,18 +369,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod if (isSaving) return; let connection: PreparedDirectConnection; - let headers: Record | undefined; try { connection = prepareDirectConnection( { host, port, useTls, password }, directConnectionLabels, ); - const preparedHeaders = prepareConnectionHeaders(headerDrafts); - if (preparedHeaders.issue) { - setErrorMessage(headerIssueMessage(preparedHeaders.issue, t)); - return; - } - headers = preparedHeaders.headers; } catch (error) { const message = error instanceof Error ? error.message : directConnectionLabels.invalidConnection; @@ -518,7 +389,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod endpoint: connection.endpoint, useTls: connection.useTls, ...(connection.password ? { password: connection.password } : {}), - ...(headers ? { headers } : {}), }); const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); @@ -555,7 +425,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod daemons, directConnectionLabels, handleClose, - headerDrafts, host, isMobile, isSaving, @@ -584,22 +453,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod 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(() => { if (!isAdvancedOpen) { try { @@ -756,58 +609,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod {t("pairing.direct.advanced.label")} {isAdvancedOpen ? ( - - - - {isNative || getIsElectron() ? ( - - - {t("pairing.direct.headers.title")} - - - - {headerDrafts.map((draft, index) => ( - - ))} - - - ) : null} - + ) : null} {errorMessage ? {errorMessage} : null} diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts index d75c2e8da..a9fe2497c 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { - createDesktopLocalDaemonTransportFactory, - createDesktopWebSocketTransportFactory, -} from "./desktop-daemon-transport"; +import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport"; import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc"; 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(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"], - }, - ]); - }); }); diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts index 1dd3d80b9..32f5ed6cf 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -2,13 +2,11 @@ import type { DaemonTransport, DaemonTransportFactory, } from "@getpaseo/client/internal/daemon-client"; -import type { DesktopDaemonTransportTarget, LocalTransportTarget } from "./desktop-daemon"; +import type { LocalTransportTarget } from "./desktop-daemon"; import { defaultLocalDaemonTransportRpc, - defaultWebSocketDaemonTransportRpc, type LocalDaemonTransportRpc, } from "./local-daemon-transport-rpc"; -import { isElectronRuntime } from "@/desktop/host"; const LOCAL_TRANSPORT_SCHEME = "paseo+local:"; @@ -52,12 +50,11 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget { }; } -function createDesktopDaemonTransportFactory( - resolveTarget: (options: Parameters[0]) => DesktopDaemonTransportTarget, - rpc: LocalDaemonTransportRpc, -): DaemonTransportFactory { - return (options) => { - const target = resolveTarget(options); +export function createDesktopLocalDaemonTransportFactory( + rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc, +): DaemonTransportFactory | null { + return ({ url }) => { + const target = parseLocalDaemonTransportUrl(url); let sessionId: string | null = null; let unlisten: (() => void) | null = null; let disposed = false; @@ -190,26 +187,3 @@ function createDesktopDaemonTransportFactory( 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, - ); -} diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index c66588de2..af202a73a 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -46,16 +46,6 @@ export interface LocalTransportTarget { transportPath: string; } -export interface WebSocketTransportTarget { - [key: string]: unknown; - transportType: "websocket"; - url: string; - headers?: Record; - protocols?: string[]; -} - -export type DesktopDaemonTransportTarget = LocalTransportTarget | WebSocketTransportTarget; - interface LocalTransportEventPayload { sessionId: string; kind: "open" | "message" | "close" | "error"; @@ -203,16 +193,6 @@ export async function openLocalTransportSession(target: LocalTransportTarget): P return raw; } -export async function openWebSocketTransportSession( - target: WebSocketTransportTarget, -): Promise { - const raw = await invokeDesktopCommand("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: { sessionId: string; text?: string; diff --git a/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts index 544db85a5..54961107e 100644 --- a/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts +++ b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts @@ -2,10 +2,8 @@ import { closeLocalTransportSession, listenToLocalTransportEvents, openLocalTransportSession, - openWebSocketTransportSession, sendLocalTransportMessage, - type DesktopDaemonTransportTarget, - type WebSocketTransportTarget, + type LocalTransportTarget, } from "./desktop-daemon"; export interface LocalDaemonTransportEvent { @@ -19,7 +17,7 @@ export interface LocalDaemonTransportEvent { } export interface LocalDaemonTransportRpc { - openSession(target: DesktopDaemonTransportTarget): Promise; + openSession(target: LocalTransportTarget): Promise; listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>; sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise; closeSession(sessionId: string): Promise; @@ -31,10 +29,3 @@ export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = { sendMessage: sendLocalTransportMessage, closeSession: closeLocalTransportSession, }; - -export const defaultWebSocketDaemonTransportRpc: LocalDaemonTransportRpc = { - openSession: (target) => openWebSocketTransportSession(target as WebSocketTransportTarget), - listenToEvents: listenToLocalTransportEvents, - sendMessage: sendLocalTransportMessage, - closeSession: closeLocalTransportSession, -}; diff --git a/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts index 26c55e6f8..8f2214b03 100644 --- a/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts +++ b/packages/app/src/desktop/daemon/test-local-daemon-transport-rpc.ts @@ -1,4 +1,4 @@ -import type { DesktopDaemonTransportTarget } from "./desktop-daemon"; +import type { LocalTransportTarget } from "./desktop-daemon"; import type { LocalDaemonTransportEvent, LocalDaemonTransportRpc, @@ -11,7 +11,7 @@ export interface RecordedSend { } export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc { - readonly openCalls: DesktopDaemonTransportTarget[]; + readonly openCalls: LocalTransportTarget[]; readonly recordedSends: RecordedSend[]; readonly closedSessions: string[]; resolveOpen(sessionId: string): void; @@ -22,7 +22,7 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc { } export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc { - const openCalls: DesktopDaemonTransportTarget[] = []; + const openCalls: LocalTransportTarget[] = []; const recordedSends: RecordedSend[] = []; const closedSessions: string[] = []; let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a231a7042..2329fea34 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1426,19 +1426,6 @@ export const ar: TranslationResources = { show: "عرض متقدم", hide: "إخفاء المتقدمة", }, - headers: { - title: "رؤوس مخصصة", - add: "إضافة رأس", - name: "الاسم", - value: "القيمة", - remove: "إزالة الرأس", - errors: { - missingName: "أدخل اسمًا لكل رأس", - invalidName: "{{name}} ليس اسم رأس صالحًا", - invalidValue: "يحتوي {{name}} على فاصل أسطر غير صالح", - duplicateName: "تم إدخال {{name}} أكثر من مرة", - }, - }, passwordVisibility: { show: "إظهار كلمة المرور", hide: "إخفاء كلمة المرور", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 765dd2b6a..357ccef58 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1437,19 +1437,6 @@ export const en = { show: "Show 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: { show: "Show password", hide: "Hide password", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index dd56db59b..af64e4e76 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1467,19 +1467,6 @@ export const es: TranslationResources = { show: "Mostrar 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: { show: "Mostrar contraseña", hide: "Ocultar contraseña", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 00b061a88..3cd4fb551 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1471,19 +1471,6 @@ export const fr: TranslationResources = { show: "Afficher 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: { show: "Afficher le mot de passe", hide: "Masquer le mot de passe", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 2ec9f2334..a4a5b139f 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1440,19 +1440,6 @@ export const ja: TranslationResources = { show: "詳細設定を表示", hide: "詳細設定を非表示", }, - headers: { - title: "カスタムヘッダー", - add: "ヘッダーを追加", - name: "名前", - value: "値", - remove: "ヘッダーを削除", - errors: { - missingName: "各ヘッダーの名前を入力してください", - invalidName: "{{name}} は有効なヘッダー名ではありません", - invalidValue: "{{name}} に無効な改行が含まれています", - duplicateName: "{{name}} が複数回入力されています", - }, - }, passwordVisibility: { show: "パスワードを表示", hide: "パスワードを非表示", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 4aa353f60..9d43e6ebc 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1436,19 +1436,6 @@ export const ko: TranslationResources = { show: "고급 표시", hide: "고급 숨기기", }, - headers: { - title: "사용자 지정 헤더", - add: "헤더 추가", - name: "이름", - value: "값", - remove: "헤더 삭제", - errors: { - missingName: "각 헤더의 이름을 입력하세요", - invalidName: "{{name}}은(는) 올바른 헤더 이름이 아닙니다", - invalidValue: "{{name}}에 잘못된 줄바꿈이 있습니다", - duplicateName: "{{name}}이(가) 두 번 이상 입력되었습니다", - }, - }, passwordVisibility: { show: "비밀번호 표시", hide: "비밀번호 숨기기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b5dfbb388..3659ae9bc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1453,19 +1453,6 @@ export const ptBR: TranslationResources = { show: "Mostrar 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: { show: "Mostrar senha", hide: "Ocultar senha", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 2e387d70d..c4053e568 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1458,19 +1458,6 @@ export const ru: TranslationResources = { show: "Показать расширенные", hide: "Скрыть расширенные", }, - headers: { - title: "Пользовательские заголовки", - add: "Добавить заголовок", - name: "Имя", - value: "Значение", - remove: "Удалить заголовок", - errors: { - missingName: "Введите имя для каждого заголовка", - invalidName: "{{name}} — недопустимое имя заголовка", - invalidValue: "{{name}} содержит недопустимый перенос строки", - duplicateName: "{{name}} введён несколько раз", - }, - }, passwordVisibility: { show: "Показать пароль", hide: "Скрыть пароль", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index ab79c6826..e4c1fbddb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1409,19 +1409,6 @@ export const zhCN: TranslationResources = { show: "显示高级选项", hide: "隐藏高级选项", }, - headers: { - title: "自定义请求头", - add: "添加请求头", - name: "名称", - value: "值", - remove: "删除请求头", - errors: { - missingName: "请为每个请求头输入名称", - invalidName: "{{name}} 不是有效的请求头名称", - invalidValue: "{{name}} 包含无效换行符", - duplicateName: "{{name}} 输入了多次", - }, - }, passwordVisibility: { show: "显示密码", hide: "隐藏密码", diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 17a50e4ed..697872f09 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -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({ deps: { createClient: () => new FakeDaemonClient() as unknown as DaemonClient, @@ -3060,7 +3060,6 @@ describe("HostRuntimeStore", () => { endpoint: "example.paseo.test:7443", useTls: true, password: "shared-secret", - headers: { "X-Tenant": "acme" }, label: "tls host", }); @@ -3072,7 +3071,6 @@ describe("HostRuntimeStore", () => { endpoint: "example.paseo.test:7443", useTls: true, password: "shared-secret", - headers: { "X-Tenant": "acme" }, }, ]); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 7e82f9e76..982d411e4 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -36,7 +36,6 @@ import { import { buildLocalDaemonTransportUrl, createDesktopLocalDaemonTransportFactory, - createDesktopWebSocketTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; import { getDesktopHost } from "@/desktop/host"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; @@ -479,10 +478,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { return { createClient: ({ host, connection, clientId, runtimeGeneration }) => { const localTransportFactory = createDesktopLocalDaemonTransportFactory(); - const webSocketTransportFactory = createDesktopWebSocketTransportFactory(); - const webSocketConfig = webSocketTransportFactory - ? { transportFactory: webSocketTransportFactory } - : { webSocketFactory: createAppWebSocketFactory() }; + const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() }; const base = { suppressSendErrors: true, clientId, @@ -510,7 +506,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps { useTls: connection.useTls ?? false, }), ...(connection.password ? { password: connection.password } : {}), - ...(connection.headers ? { headers: connection.headers } : {}), }); } return new DaemonClient({ @@ -1651,7 +1646,6 @@ export class HostRuntimeStore { endpoint: string; useTls?: boolean; password?: string; - headers?: Record; label?: string; existingClient?: DaemonClient; }): Promise { @@ -1666,7 +1660,6 @@ export class HostRuntimeStore { endpoint, useTls: input.useTls ?? false, ...(password ? { password } : {}), - ...(input.headers ? { headers: input.headers } : {}), }, existingClient: input.existingClient, }); @@ -1708,7 +1701,6 @@ export class HostRuntimeStore { endpoint: string; useTls?: boolean; password?: string; - headers?: Record; label?: string; }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { const endpoint = normalizeHostPort(input.endpoint); @@ -1721,7 +1713,6 @@ export class HostRuntimeStore { endpoint, useTls: input.useTls ?? false, ...(password ? { password } : {}), - ...(input.headers ? { headers: input.headers } : {}), }, }); } @@ -2463,14 +2454,12 @@ export interface HostMutations { endpoint: string; useTls?: boolean; password?: string; - headers?: Record; label?: string; }) => Promise; probeAndUpsertDirectConnection: (input: { endpoint: string; useTls?: boolean; password?: string; - headers?: Record; label?: string; }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; upsertRelayConnection: (input: { diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index dcb92ba54..88194dc3b 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -75,49 +75,6 @@ describe("normalizeStoredHostProfile", () => { 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", () => { const profile = normalizeStoredHostProfile({ serverId: "srv_relay", @@ -219,59 +176,33 @@ describe("upsertHostConnectionInProfiles", () => { expect(profile.appearance).toEqual({ color: "amber", badgeDisplay: "hidden" }); }); - it("deduplicates matching custom headers regardless of key order", () => { - 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", () => { + it("replaces a direct connection when its settings change", () => { const existingConnection: HostConnection = { id: "direct:example.test:6767", type: "directTcp", endpoint: "example.test:6767", - headers: { "X-Tenant": "old" }, + useTls: false, + password: "old-secret", }; - const existing = { + const existing: HostProfile = { ...makeHost("srv_known"), connections: [existingConnection], preferredConnectionId: existingConnection.id, }; + const replacement: HostConnection = { + ...existingConnection, + useTls: true, + password: "new-secret", + }; const [profile] = upsertHostConnectionInProfiles({ profiles: [existing], serverId: "srv_known", - connection: { - ...existingConnection, - headers: { "X-Tenant": "new" }, - }, + connection: replacement, }); - expect(profile.connections).toEqual([ - { - ...existingConnection, - headers: { "X-Tenant": "new" }, - }, - ]); - expect(profile.preferredConnectionId).toBe(existingConnection.id); + expect(profile.connections).toEqual([replacement]); + expect(profile.preferredConnectionId).toBe(replacement.id); }); }); diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index a6312f2d3..421f5868c 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -11,7 +11,6 @@ import { defaultHostAppearance, normalizeStoredHostAppearance, } from "@/hosts/appearance"; -import { normalizeConnectionHeadersRecord } from "@/utils/connection-headers"; export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; @@ -105,18 +104,6 @@ export function resolveActiveHostServerId(params: { ); } -function stringRecordEquals( - left: Record | undefined, - right: Record | 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 { if (left.type !== right.type || left.id !== right.id) { return false; @@ -126,8 +113,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool return ( left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false) && - left.password === right.password && - stringRecordEquals(left.headers, right.headers) + left.password === right.password ); } if (left.type === "directSocket" && right.type === "directSocket") { @@ -315,11 +301,6 @@ function toObjectRecord(value: unknown): Record | undefined { return isPlainRecord(value) ? value : undefined; } -function normalizeStoredHeaders(value: unknown): { headers?: Record } { - const headers = normalizeConnectionHeadersRecord(value); - return headers ? { headers } : {}; -} - function normalizeStoredConnection(connection: unknown): HostConnection | null { const record = toObjectRecord(connection); if (!record) { @@ -337,7 +318,6 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { endpoint, useTls: record.useTls, ...(typeof record.password === "string" ? { password: record.password } : {}), - ...normalizeStoredHeaders(record.headers), }); } catch { return null; diff --git a/packages/app/src/utils/connection-headers.test.ts b/packages/app/src/utils/connection-headers.test.ts deleted file mode 100644 index ca9a80783..000000000 --- a/packages/app/src/utils/connection-headers.test.ts +++ /dev/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(); - }); -}); diff --git a/packages/app/src/utils/connection-headers.ts b/packages/app/src/utils/connection-headers.ts deleted file mode 100644 index c63ca6904..000000000 --- a/packages/app/src/utils/connection-headers.ts +++ /dev/null @@ -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 { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -export function prepareConnectionHeaders(drafts: readonly ConnectionHeaderDraft[]): { - headers?: Record; - issue?: ConnectionHeaderValidationIssue; -} { - const headers: Record = {}; - const normalizedNames = new Set(); - - 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 | 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; -} diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index cae1762d5..5ccee7e75 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -101,6 +101,29 @@ describe("test-daemon-connection connectToDaemon", () => { 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 () => { const { connectToDaemon } = await import("./test-daemon-connection"); const result = await connectToDaemon( @@ -134,46 +157,6 @@ describe("test-daemon-connection connectToDaemon", () => { 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 = () => { - 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 () => { const { connectToDaemon } = await import("./test-daemon-connection"); const trace = { diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index abd19d5c1..381cb1afb 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -11,7 +11,6 @@ import { import { buildLocalDaemonTransportUrl, createDesktopLocalDaemonTransportFactory, - createDesktopWebSocketTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; export interface DaemonProbeClient { @@ -30,7 +29,6 @@ export interface DaemonConnectionDependencies getClientId(): Promise; resolveAppVersion(): string | null; createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null; - createWebSocketTransportFactory?(): DaemonClientConfig["transportFactory"] | null; buildLocalTransportUrl(input: LocalTransportUrlInput): string; createClient(config: DaemonClientConfig): TClient; } @@ -39,17 +37,10 @@ const defaultDaemonConnectionDependencies: DaemonConnectionDependencies new DaemonClient(config), }; -function resolveWebSocketTransportFactory( - deps: Pick, "createWebSocketTransportFactory">, -): DaemonClientConfig["transportFactory"] { - return deps.createWebSocketTransportFactory?.() ?? undefined; -} - function normalizeNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -112,11 +103,7 @@ export async function buildClientConfig( }, deps: Pick< DaemonConnectionDependencies, - | "getClientId" - | "resolveAppVersion" - | "createLocalTransportFactory" - | "createWebSocketTransportFactory" - | "buildLocalTransportUrl" + "getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl" > = defaultDaemonConnectionDependencies, ): Promise { const clientId = await deps.getClientId(); @@ -148,10 +135,8 @@ export async function buildClientConfig( if (connection.type === "directTcp") { return { ...base, - transportFactory: resolveWebSocketTransportFactory(deps), url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }), ...(connection.password ? { password: connection.password } : {}), - ...(connection.headers ? { headers: connection.headers } : {}), }; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 86388cbd2..1c9b7faf6 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -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 () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 89251bdc5..745212900 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -301,7 +301,6 @@ export interface DaemonClientConfig { runtimeGeneration?: number | null; password?: string; authHeader?: string; - headers?: Record; suppressSendErrors?: boolean; transportFactory?: DaemonTransportFactory; webSocketFactory?: WebSocketFactory; @@ -1246,7 +1245,7 @@ export class DaemonClient { return; } - const headers: Record = { ...this.config.headers }; + const headers: Record = {}; const password = normalizePassword(this.config.password); if (password) { headers.Authorization = `Bearer ${password}`; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d75fbc74e..a0fdbd5e7 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -57,7 +57,6 @@ export interface PaseoClientConfig { runtimeGeneration?: number | null; password?: string; authHeader?: string; - headers?: Record; suppressSendErrors?: boolean; logger?: PaseoLogger; connectTimeoutMs?: number; diff --git a/packages/desktop/e2e/direct-connection-headers.spec.ts b/packages/desktop/e2e/direct-connection-headers.spec.ts deleted file mode 100644 index 78cc5c20d..000000000 --- a/packages/desktop/e2e/direct-connection-headers.spec.ts +++ /dev/null @@ -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); -}); diff --git a/packages/desktop/e2e/pair-device-relay.spec.ts b/packages/desktop/e2e/pair-device-relay.spec.ts index c678b2a95..2f23852c2 100644 --- a/packages/desktop/e2e/pair-device-relay.spec.ts +++ b/packages/desktop/e2e/pair-device-relay.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from "../../app/e2e/support/fixtures"; -import type { Page } from "@playwright/test"; import { startIsolatedHostDaemon, type IsolatedHostDaemon, @@ -19,12 +18,11 @@ import { openPairDeviceFromHome, openRelaySecurityDocs, observePairingOfferRequests, - preparePairingHost, + prepareLocalPairingHost, reloadAndOpenPairDevice, retryRelayAndExpectFailure, switchPairDeviceToHost, } from "../../app/e2e/support/helpers/pair-device"; -import { installDesktopRuntime } from "./support/runtime"; interface RelayConfigDaemonClient { close(): Promise; @@ -32,20 +30,6 @@ interface RelayConfigDaemonClient { patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise; } -async function prepareLocalPairingHost( - page: Page, - daemon: Parameters[1], - additionalHosts: Parameters[2] = [], -): Promise { - 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", () => { let relayOffDaemon: IsolatedHostDaemon; let relayEnableDaemon: IsolatedHostDaemon; diff --git a/packages/desktop/e2e/settings-sidebar-scroll.spec.ts b/packages/desktop/e2e/settings-sidebar-scroll.spec.ts index 0c34d6071..942fcdb42 100644 --- a/packages/desktop/e2e/settings-sidebar-scroll.spec.ts +++ b/packages/desktop/e2e/settings-sidebar-scroll.spec.ts @@ -1,15 +1,24 @@ import { test, expect } from "../../app/e2e/support/fixtures"; 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.use({ viewport: { width: 900, height: 260 } }); test("desktop drag region does not cover the scroll body", async ({ page }) => { - await installDesktopRuntime(page, { - serverId: getServerId(), - manageBuiltInDaemon: true, + await page.addInitScript(() => { + window.paseoDesktop = { + 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); diff --git a/packages/desktop/e2e/support/runtime.ts b/packages/desktop/e2e/support/runtime.ts index 7b0869af2..fdffe1f31 100644 --- a/packages/desktop/e2e/support/runtime.ts +++ b/packages/desktop/e2e/support/runtime.ts @@ -139,123 +139,8 @@ export async function installDesktopRuntime( let daemonRunning = true; let currentPid: number | null = cfg.daemonPid ?? null; let startCount = 0; - let nextTransportSessionId = 0; - const transportSessions = new Map(); - const desktopEventHandlers = new Map void>>(); 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): { - 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) - : []; - 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): Promise { - 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((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() { return { serverId: cfg.serverId, @@ -290,55 +175,6 @@ export async function installDesktopRuntime( } } - function patchDesktopSettings(args?: Record) { - 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): 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): null { - const sessionId = typeof args?.sessionId === "string" ? args.sessionId : ""; - transportSessions.get(sessionId)?.close(); - transportSessions.delete(sessionId); - return null; - } - - const webSocketTransportCommandHandlers: Record< - string, - (args?: Record) => unknown - > = { - open_websocket_daemon_transport: openWebSocketTransport, - send_local_daemon_transport_message: sendWebSocketTransportMessage, - close_local_daemon_transport: closeWebSocketTransport, - }; - const desktopBridge: { platform: string; invoke: (command: string, args?: Record) => Promise; @@ -347,10 +183,7 @@ export async function installDesktopRuntime( open: (options?: Record) => Promise; }; getPendingOpenProject: () => Promise; - opener: { openUrl: (url: string) => Promise }; - events: { - on: (event: string, handler: (payload: unknown) => void) => Promise<() => void>; - }; + events: { on: () => Promise<() => void> }; editor?: { listTargets: () => Promise; openTarget: (input: DesktopEditorOpenRecord) => Promise; @@ -406,7 +239,19 @@ export async function installDesktopRuntime( } 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") { @@ -419,11 +264,6 @@ export async function installDesktopRuntime( return startDesktopDaemon(); } - const transportCommandHandler = webSocketTransportCommandHandlers[command]; - if (transportCommandHandler) { - return await transportCommandHandler(args); - } - return null; }, dialog: { @@ -440,24 +280,7 @@ export async function installDesktopRuntime( }, }, getPendingOpenProject: async () => null, - opener: { - 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); - } - }; - }, - }, + events: { on: async () => () => undefined }, }; if (cfg.editorTargets) { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index bae550ef6..faf4a7a10 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -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 { - 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 }, diff --git a/packages/desktop/src/daemon/local-transport.test.ts b/packages/desktop/src/daemon/local-transport.test.ts deleted file mode 100644 index d356d5d11..000000000 --- a/packages/desktop/src/daemon/local-transport.test.ts +++ /dev/null @@ -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>((resolve) => { - webSocketServer.once("connection", (_socket, request) => resolve(request.headers)); - }); - - await new Promise((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((resolve, reject) => { - webSocketServer.close((error) => (error ? reject(error) : resolve())); - }); - await new Promise((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"); - }); -}); diff --git a/packages/desktop/src/daemon/local-transport.ts b/packages/desktop/src/daemon/local-transport.ts index 65d45ace9..c3d5227f4 100644 --- a/packages/desktop/src/daemon/local-transport.ts +++ b/packages/desktop/src/daemon/local-transport.ts @@ -6,13 +6,6 @@ interface TransportTarget { transportPath: string; } -export interface WebSocketTransportTarget { - transportType: "websocket"; - url: string; - headers?: Record; - 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(); @@ -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 = {}; - 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 { - return openTransportSession(buildLocalWebSocketUrl(target), describeTransportTarget(target)); -} - -export function openWebSocketTransportSession(target: WebSocketTransportTarget): Promise { - 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; protocols?: string[] }, -): Promise { 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; } diff --git a/packages/protocol/src/host-connection-schema.ts b/packages/protocol/src/host-connection-schema.ts index 2d568c1c8..86a98b0e4 100644 --- a/packages/protocol/src/host-connection-schema.ts +++ b/packages/protocol/src/host-connection-schema.ts @@ -6,7 +6,6 @@ export const DirectTcpHostConnectionSchema = z.object({ endpoint: z.string(), useTls: z.boolean().optional().default(false), password: z.string().optional(), - headers: z.record(z.string(), z.string()).optional(), }); export type DirectTcpHostConnection = z.input;