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