From 73ba37a07aea419698b237cf45d78e59793cd5b4 Mon Sep 17 00:00:00 2001 From: Claude <41422239+claudesortwell@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:05:40 +1000 Subject: [PATCH] Add custom headers to direct connections (#2922) * Add custom headers to direct connections * Address custom header review feedback * Fix direct connection header integration * Fix desktop relay E2E transport setup --------- Co-authored-by: Claude Co-authored-by: Mohamed Boudra --- .../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 | 10 + .../app/src/types/host-connection.test.ts | 98 ++++++++ packages/app/src/types/host-connection.ts | 43 +++- .../app/src/utils/connection-headers.test.ts | 56 +++++ packages/app/src/utils/connection-headers.ts | 65 ++++++ .../src/utils/test-daemon-connection.test.ts | 40 ++++ .../app/src/utils/test-daemon-connection.ts | 17 +- .../src/daemon-client-transport.test.ts | 23 ++ .../src/daemon-client-websocket-transport.ts | 8 +- 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 + 38 files changed, 1190 insertions(+), 125 deletions(-) create mode 100644 packages/app/src/utils/connection-headers.test.ts create mode 100644 packages/app/src/utils/connection-headers.ts create mode 100644 packages/desktop/e2e/direct-connection-headers.spec.ts create 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 5a3624f06..3657f2293 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -114,6 +114,16 @@ 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 a454cdf2d..b1c15de82 100644 --- a/packages/app/e2e/support/helpers/pair-device.ts +++ b/packages/app/e2e/support/helpers/pair-device.ts @@ -17,49 +17,6 @@ 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 6bd72e2ad..dc7ef43d6 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -1,9 +1,10 @@ -import { useCallback, useMemo, useReducer, useState } from "react"; +import { useCallback, useMemo, useReducer, useRef, 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 { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; +import { getIsElectron, isNative } from "@/constants/platform"; +import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { @@ -12,6 +13,11 @@ 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"; @@ -123,6 +129,30 @@ 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], @@ -181,6 +211,94 @@ 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 { @@ -305,6 +423,8 @@ 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(() => { @@ -315,6 +435,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod setIsPasswordVisible(false); setIsAdvancedOpen(false); setAdvancedUri(""); + setHeaderDrafts([]); + nextHeaderId.current = 1; bumpInputResetKey(); }, []); @@ -369,11 +491,18 @@ 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; @@ -389,6 +518,7 @@ 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); @@ -425,6 +555,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod daemons, directConnectionLabels, handleClose, + headerDrafts, host, isMobile, isSaving, @@ -453,6 +584,22 @@ 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 { @@ -609,24 +756,58 @@ 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 a9fe2497c..d75c2e8da 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport"; +import { + createDesktopLocalDaemonTransportFactory, + createDesktopWebSocketTransportFactory, +} from "./desktop-daemon-transport"; import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc"; const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"; @@ -43,4 +46,25 @@ 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 32f5ed6cf..1dd3d80b9 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -2,11 +2,13 @@ import type { DaemonTransport, DaemonTransportFactory, } from "@getpaseo/client/internal/daemon-client"; -import type { LocalTransportTarget } from "./desktop-daemon"; +import type { DesktopDaemonTransportTarget, 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:"; @@ -50,11 +52,12 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget { }; } -export function createDesktopLocalDaemonTransportFactory( - rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc, -): DaemonTransportFactory | null { - return ({ url }) => { - const target = parseLocalDaemonTransportUrl(url); +function createDesktopDaemonTransportFactory( + resolveTarget: (options: Parameters[0]) => DesktopDaemonTransportTarget, + rpc: LocalDaemonTransportRpc, +): DaemonTransportFactory { + return (options) => { + const target = resolveTarget(options); let sessionId: string | null = null; let unlisten: (() => void) | null = null; let disposed = false; @@ -187,3 +190,26 @@ export function createDesktopLocalDaemonTransportFactory( 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 af202a73a..c66588de2 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -46,6 +46,16 @@ 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"; @@ -193,6 +203,16 @@ 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 54961107e..544db85a5 100644 --- a/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts +++ b/packages/app/src/desktop/daemon/local-daemon-transport-rpc.ts @@ -2,8 +2,10 @@ import { closeLocalTransportSession, listenToLocalTransportEvents, openLocalTransportSession, + openWebSocketTransportSession, sendLocalTransportMessage, - type LocalTransportTarget, + type DesktopDaemonTransportTarget, + type WebSocketTransportTarget, } from "./desktop-daemon"; export interface LocalDaemonTransportEvent { @@ -17,7 +19,7 @@ export interface LocalDaemonTransportEvent { } export interface LocalDaemonTransportRpc { - openSession(target: LocalTransportTarget): Promise; + openSession(target: DesktopDaemonTransportTarget): Promise; listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>; sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise; closeSession(sessionId: string): Promise; @@ -29,3 +31,10 @@ 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 8f2214b03..26c55e6f8 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 { LocalTransportTarget } from "./desktop-daemon"; +import type { DesktopDaemonTransportTarget } from "./desktop-daemon"; import type { LocalDaemonTransportEvent, LocalDaemonTransportRpc, @@ -11,7 +11,7 @@ export interface RecordedSend { } export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc { - readonly openCalls: LocalTransportTarget[]; + readonly openCalls: DesktopDaemonTransportTarget[]; 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: LocalTransportTarget[] = []; + const openCalls: DesktopDaemonTransportTarget[] = []; 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 0d26cd0d9..117e4745a 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1405,6 +1405,19 @@ 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 1bf54722e..80c5bcc74 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1416,6 +1416,19 @@ 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 7510f75d9..ae37657c0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1446,6 +1446,19 @@ 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 00d9e99b9..dc3ebc00d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1450,6 +1450,19 @@ 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 e3d0b78aa..fa62013ec 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1419,6 +1419,19 @@ 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 465e1a8f1..e7052017c 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1415,6 +1415,19 @@ 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 c68a16752..098341b38 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1432,6 +1432,19 @@ 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 1b006577a..c791b8499 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1437,6 +1437,19 @@ 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 5c9ea2e29..b040b5818 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1388,6 +1388,19 @@ 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 fd79fe249..37fc6b379 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -2998,7 +2998,7 @@ describe("HostRuntimeStore", () => { } }); - it("upsertDirectConnection stores SSL and password settings", async () => { + it("upsertDirectConnection stores SSL, password, and custom header settings", async () => { const store = new HostRuntimeStore({ deps: { createClient: () => new FakeDaemonClient() as unknown as DaemonClient, @@ -3016,6 +3016,7 @@ describe("HostRuntimeStore", () => { endpoint: "example.paseo.test:7443", useTls: true, password: "shared-secret", + headers: { "X-Tenant": "acme" }, label: "tls host", }); @@ -3027,6 +3028,7 @@ 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 488ede2eb..5f0e2ca1a 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -36,6 +36,7 @@ import { import { buildLocalDaemonTransportUrl, createDesktopLocalDaemonTransportFactory, + createDesktopWebSocketTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; import { getDesktopHost } from "@/desktop/host"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; @@ -477,6 +478,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { return { createClient: ({ host, connection, clientId, runtimeGeneration }) => { const localTransportFactory = createDesktopLocalDaemonTransportFactory(); + const webSocketTransportFactory = createDesktopWebSocketTransportFactory(); const base = { suppressSendErrors: true, clientId, @@ -499,10 +501,12 @@ function createDefaultDeps(): HostRuntimeControllerDeps { if (connection.type === "directTcp") { return new DaemonClient({ ...base, + ...(webSocketTransportFactory ? { transportFactory: webSocketTransportFactory } : {}), url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false, }), ...(connection.password ? { password: connection.password } : {}), + ...(connection.headers ? { headers: connection.headers } : {}), }); } return new DaemonClient({ @@ -1640,6 +1644,7 @@ export class HostRuntimeStore { endpoint: string; useTls?: boolean; password?: string; + headers?: Record; label?: string; existingClient?: DaemonClient; }): Promise { @@ -1654,6 +1659,7 @@ export class HostRuntimeStore { endpoint, useTls: input.useTls ?? false, ...(password ? { password } : {}), + ...(input.headers ? { headers: input.headers } : {}), }, existingClient: input.existingClient, }); @@ -1695,6 +1701,7 @@ 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); @@ -1707,6 +1714,7 @@ export class HostRuntimeStore { endpoint, useTls: input.useTls ?? false, ...(password ? { password } : {}), + ...(input.headers ? { headers: input.headers } : {}), }, }); } @@ -2431,12 +2439,14 @@ 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 14a110f4e..dcb92ba54 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -75,6 +75,49 @@ 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", @@ -175,6 +218,61 @@ 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", () => { + const existingConnection: HostConnection = { + id: "direct:example.test:6767", + type: "directTcp", + endpoint: "example.test:6767", + headers: { "X-Tenant": "old" }, + }; + const existing = { + ...makeHost("srv_known"), + connections: [existingConnection], + preferredConnectionId: existingConnection.id, + }; + + const [profile] = upsertHostConnectionInProfiles({ + profiles: [existing], + serverId: "srv_known", + connection: { + ...existingConnection, + headers: { "X-Tenant": "new" }, + }, + }); + + expect(profile.connections).toEqual([ + { + ...existingConnection, + headers: { "X-Tenant": "new" }, + }, + ]); + expect(profile.preferredConnectionId).toBe(existingConnection.id); + }); }); describe("resolveActiveHostServerId", () => { diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 26da38375..a6312f2d3 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -11,6 +11,7 @@ import { defaultHostAppearance, normalizeStoredHostAppearance, } from "@/hosts/appearance"; +import { normalizeConnectionHeadersRecord } from "@/utils/connection-headers"; export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; @@ -104,6 +105,18 @@ 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; @@ -113,7 +126,8 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool return ( left.endpoint === right.endpoint && (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") { @@ -137,14 +151,23 @@ function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean return JSON.stringify(left) === JSON.stringify(right); } -function dedupeHostConnections(connections: HostConnection[]): HostConnection[] { +function upsertHostConnectionById( + connections: HostConnection[], + connection: HostConnection, +): HostConnection[] { const next: HostConnection[] = []; - for (const connection of connections) { - if (next.some((existing) => hostConnectionEquals(existing, connection))) { + let replaced = false; + for (const existing of connections) { + if (existing.id !== connection.id) { + next.push(existing); continue; } + + if (replaced) continue; next.push(connection); + replaced = true; } + if (!replaced) next.push(connection); return next; } @@ -190,10 +213,10 @@ export function upsertHostConnectionInProfiles(input: { const matchedProfiles = matchingIndexes.map((index) => existing[index]); const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]; - const nextConnections = dedupeHostConnections([ - ...matchedProfiles.flatMap((daemon) => daemon.connections), + const nextConnections = upsertHostConnectionById( + matchedProfiles.flatMap((daemon) => daemon.connections), input.connection, - ]); + ); const nextLifecycle = prev.lifecycle; const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label; const nextPreferredConnectionId = @@ -292,6 +315,11 @@ 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) { @@ -309,6 +337,7 @@ 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 new file mode 100644 index 000000000..ca9a80783 --- /dev/null +++ b/packages/app/src/utils/connection-headers.test.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 000000000..c63ca6904 --- /dev/null +++ b/packages/app/src/utils/connection-headers.ts @@ -0,0 +1,65 @@ +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 c13f0593d..cae1762d5 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -134,6 +134,46 @@ 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 381cb1afb..abd19d5c1 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -11,6 +11,7 @@ import { import { buildLocalDaemonTransportUrl, createDesktopLocalDaemonTransportFactory, + createDesktopWebSocketTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; export interface DaemonProbeClient { @@ -29,6 +30,7 @@ export interface DaemonConnectionDependencies getClientId(): Promise; resolveAppVersion(): string | null; createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null; + createWebSocketTransportFactory?(): DaemonClientConfig["transportFactory"] | null; buildLocalTransportUrl(input: LocalTransportUrlInput): string; createClient(config: DaemonClientConfig): TClient; } @@ -37,10 +39,17 @@ 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(); @@ -103,7 +112,11 @@ export async function buildClientConfig( }, deps: Pick< DaemonConnectionDependencies, - "getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl" + | "getClientId" + | "resolveAppVersion" + | "createLocalTransportFactory" + | "createWebSocketTransportFactory" + | "buildLocalTransportUrl" > = defaultDaemonConnectionDependencies, ): Promise { const clientId = await deps.getClientId(); @@ -135,8 +148,10 @@ 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-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 378275c67..347d94302 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -9,6 +9,7 @@ import { encodeUtf8String, extractRelayMessage, } from "./daemon-client-transport.js"; +import { defaultWebSocketFactory } from "./daemon-client-websocket-transport.js"; const createClientChannelMock = vi.hoisted(() => vi.fn()); @@ -17,6 +18,28 @@ vi.mock("@getpaseo/relay/e2ee", () => ({ })); describe("daemon-client transport helpers", () => { + test("defaultWebSocketFactory passes custom headers to native WebSocket options", () => { + const constructorSpy = vi.fn(); + function MockWebSocket( + url: string, + protocols?: string | string[], + options?: { headers?: Record }, + ) { + constructorSpy(url, protocols, options); + } + vi.stubGlobal("WebSocket", MockWebSocket); + + defaultWebSocketFactory("ws://example.test", { + protocols: ["paseo.test"], + headers: { "X-Tenant": "acme" }, + }); + + expect(constructorSpy).toHaveBeenCalledWith("ws://example.test", ["paseo.test"], { + headers: { "X-Tenant": "acme" }, + }); + vi.unstubAllGlobals(); + }); + test("createEncryptedTransport closes handshake failures with browser-safe code", async () => { createClientChannelMock.mockReset(); createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed")); diff --git a/packages/client/src/daemon-client-websocket-transport.ts b/packages/client/src/daemon-client-websocket-transport.ts index 27307ef3d..449d97890 100644 --- a/packages/client/src/daemon-client-websocket-transport.ts +++ b/packages/client/src/daemon-client-websocket-transport.ts @@ -11,13 +11,17 @@ export function defaultWebSocketFactory( ): WebSocketLike { const globalWs = ( globalThis as { - WebSocket?: new (url: string, protocols?: string | string[]) => WebSocketLike; + WebSocket?: new ( + url: string, + protocols?: string | string[], + options?: { headers?: Record }, + ) => WebSocketLike; } ).WebSocket; if (!globalWs) { throw new Error("WebSocket is not available in this runtime"); } - return new globalWs(url, options?.protocols); + return new globalWs(url, options?.protocols, { headers: options?.headers }); } export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 1c9b7faf6..86388cbd2 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -707,6 +707,33 @@ 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 72b0cb61a..50bc688eb 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -301,6 +301,7 @@ export interface DaemonClientConfig { runtimeGeneration?: number | null; password?: string; authHeader?: string; + headers?: Record; suppressSendErrors?: boolean; transportFactory?: DaemonTransportFactory; webSocketFactory?: WebSocketFactory; @@ -1249,7 +1250,7 @@ export class DaemonClient { return; } - const headers: Record = {}; + const headers: Record = { ...this.config.headers }; 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 a0fdbd5e7..d75fbc74e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -57,6 +57,7 @@ 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 new file mode 100644 index 000000000..78cc5c20d --- /dev/null +++ b/packages/desktop/e2e/direct-connection-headers.spec.ts @@ -0,0 +1,31 @@ +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 2f23852c2..c678b2a95 100644 --- a/packages/desktop/e2e/pair-device-relay.spec.ts +++ b/packages/desktop/e2e/pair-device-relay.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "../../app/e2e/support/fixtures"; +import type { Page } from "@playwright/test"; import { startIsolatedHostDaemon, type IsolatedHostDaemon, @@ -18,11 +19,12 @@ import { openPairDeviceFromHome, openRelaySecurityDocs, observePairingOfferRequests, - prepareLocalPairingHost, + preparePairingHost, reloadAndOpenPairDevice, retryRelayAndExpectFailure, switchPairDeviceToHost, } from "../../app/e2e/support/helpers/pair-device"; +import { installDesktopRuntime } from "./support/runtime"; interface RelayConfigDaemonClient { close(): Promise; @@ -30,6 +32,20 @@ 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 942fcdb42..0c34d6071 100644 --- a/packages/desktop/e2e/settings-sidebar-scroll.spec.ts +++ b/packages/desktop/e2e/settings-sidebar-scroll.spec.ts @@ -1,24 +1,15 @@ 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 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 installDesktopRuntime(page, { + serverId: getServerId(), + manageBuiltInDaemon: true, }); await gotoAppShell(page); diff --git a/packages/desktop/e2e/support/runtime.ts b/packages/desktop/e2e/support/runtime.ts index fdffe1f31..7b0869af2 100644 --- a/packages/desktop/e2e/support/runtime.ts +++ b/packages/desktop/e2e/support/runtime.ts @@ -139,8 +139,123 @@ 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, @@ -175,6 +290,55 @@ 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; @@ -183,7 +347,10 @@ export async function installDesktopRuntime( open: (options?: Record) => Promise; }; getPendingOpenProject: () => Promise; - events: { on: () => Promise<() => void> }; + opener: { openUrl: (url: string) => Promise }; + events: { + on: (event: string, handler: (payload: unknown) => void) => Promise<() => void>; + }; editor?: { listTargets: () => Promise; openTarget: (input: DesktopEditorOpenRecord) => Promise; @@ -239,19 +406,7 @@ export async function installDesktopRuntime( } if (command === "patch_desktop_settings") { - 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 }, - }; + return patchDesktopSettings(args); } if (command === "stop_desktop_daemon") { @@ -264,6 +419,11 @@ export async function installDesktopRuntime( return startDesktopDaemon(); } + const transportCommandHandler = webSocketTransportCommandHandlers[command]; + if (transportCommandHandler) { + return await transportCommandHandler(args); + } + return null; }, dialog: { @@ -280,7 +440,24 @@ export async function installDesktopRuntime( }, }, getPendingOpenProject: async () => null, - events: { on: async () => () => undefined }, + 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); + } + }; + }, + }, }; if (cfg.editorTargets) { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index faf4a7a10..bae550ef6 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -26,8 +26,10 @@ 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"; @@ -537,6 +539,9 @@ 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 new file mode 100644 index 000000000..d356d5d11 --- /dev/null +++ b/packages/desktop/src/daemon/local-transport.test.ts @@ -0,0 +1,86 @@ +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 c3d5227f4..65d45ace9 100644 --- a/packages/desktop/src/daemon/local-transport.ts +++ b/packages/desktop/src/daemon/local-transport.ts @@ -6,6 +6,13 @@ interface TransportTarget { transportPath: string; } +export interface WebSocketTransportTarget { + transportType: "websocket"; + url: string; + headers?: Record; + protocols?: string[]; +} + interface TransportEventPayload { sessionId: string; kind: "open" | "message" | "close" | "error"; @@ -23,6 +30,7 @@ interface Session { } const WS_ENDPOINT_PATH = "/ws"; +const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; let nextSessionId = 0; const sessions = new Map(); @@ -52,6 +60,28 @@ 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; @@ -65,11 +95,28 @@ 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 = new WebSocket(url); + const ws = options?.protocols + ? new WebSocket(url, options.protocols, { headers: options.headers }) + : new WebSocket(url, { headers: options?.headers }); const session: Session = { id: sessionId, ws, @@ -121,9 +168,7 @@ export function openLocalTransportSession(target: TransportTarget): Promise { if (!openSettled) { - finalizeOpenFailure( - `Failed to connect to ${describeTransportTarget(target)}: ${err.message}`, - ); + finalizeOpenFailure(`Failed to connect to ${targetDescription}: ${err.message}`); return; } diff --git a/packages/protocol/src/host-connection-schema.ts b/packages/protocol/src/host-connection-schema.ts index 86a98b0e4..2d568c1c8 100644 --- a/packages/protocol/src/host-connection-schema.ts +++ b/packages/protocol/src/host-connection-schema.ts @@ -6,6 +6,7 @@ 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;