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:
co-authored by
Claude
Mohamed Boudra
parent
098122be8d
commit
73ba37a07a
@@ -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);
|
||||
|
||||
@@ -17,49 +17,6 @@ interface PairingHostInput {
|
||||
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(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
|
||||
@@ -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<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 {
|
||||
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<ConnectionHeaderDraft[]>([]);
|
||||
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<string, string> | 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
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleToggleAdvanced}
|
||||
/>
|
||||
<View style={styles.advancedContent}>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
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}
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DaemonTransportFactory>[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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface LocalTransportTarget {
|
||||
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 {
|
||||
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<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: {
|
||||
sessionId: string;
|
||||
text?: string;
|
||||
|
||||
@@ -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<string>;
|
||||
openSession(target: DesktopDaemonTransportTarget): Promise<string>;
|
||||
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
|
||||
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
|
||||
closeSession(sessionId: string): Promise<void>;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "إخفاء كلمة المرور",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "パスワードを非表示",
|
||||
|
||||
@@ -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: "비밀번호 숨기기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Скрыть пароль",
|
||||
|
||||
@@ -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: "隐藏密码",
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
label?: string;
|
||||
existingClient?: DaemonClient;
|
||||
}): Promise<HostProfile> {
|
||||
@@ -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<string, string>;
|
||||
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<string, string>;
|
||||
label?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
probeAndUpsertDirectConnection: (input: {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
upsertRelayConnection: (input: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<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 {
|
||||
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<string, unknown> | 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 {
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const trace = {
|
||||
|
||||
@@ -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<TClient extends DaemonProbeClient>
|
||||
getClientId(): Promise<string>;
|
||||
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<DaemonCl
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
|
||||
createWebSocketTransportFactory: createDesktopWebSocketTransportFactory,
|
||||
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
|
||||
createClient: (config) => new DaemonClient(config),
|
||||
};
|
||||
|
||||
function resolveWebSocketTransportFactory(
|
||||
deps: Pick<DaemonConnectionDependencies<DaemonProbeClient>, "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<DaemonProbeClient>,
|
||||
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
||||
| "getClientId"
|
||||
| "resolveAppVersion"
|
||||
| "createLocalTransportFactory"
|
||||
| "createWebSocketTransportFactory"
|
||||
| "buildLocalTransportUrl"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<DaemonClientConfig> {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 () => {
|
||||
createClientChannelMock.mockReset();
|
||||
createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed"));
|
||||
|
||||
@@ -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<string, string> },
|
||||
) => 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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -301,6 +301,7 @@ export interface DaemonClientConfig {
|
||||
runtimeGeneration?: number | null;
|
||||
password?: string;
|
||||
authHeader?: string;
|
||||
headers?: Record<string, string>;
|
||||
suppressSendErrors?: boolean;
|
||||
transportFactory?: DaemonTransportFactory;
|
||||
webSocketFactory?: WebSocketFactory;
|
||||
@@ -1249,7 +1250,7 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = { ...this.config.headers };
|
||||
const password = normalizePassword(this.config.password);
|
||||
if (password) {
|
||||
headers.Authorization = `Bearer ${password}`;
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface PaseoClientConfig {
|
||||
runtimeGeneration?: number | null;
|
||||
password?: string;
|
||||
authHeader?: string;
|
||||
headers?: Record<string, string>;
|
||||
suppressSendErrors?: boolean;
|
||||
logger?: PaseoLogger;
|
||||
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);
|
||||
});
|
||||
@@ -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<void>;
|
||||
@@ -30,6 +32,20 @@ interface RelayConfigDaemonClient {
|
||||
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", () => {
|
||||
let relayOffDaemon: IsolatedHostDaemon;
|
||||
let relayEnableDaemon: IsolatedHostDaemon;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, WebSocket>();
|
||||
const desktopEventHandlers = new Map<string, Set<(payload: unknown) => 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<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() {
|
||||
return {
|
||||
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: {
|
||||
platform: string;
|
||||
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>;
|
||||
};
|
||||
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?: {
|
||||
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
|
||||
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, DesktopCommandHand
|
||||
const target = args as { transportType: "socket" | "pipe"; transportPath: string };
|
||||
return await openLocalTransportSession(target);
|
||||
},
|
||||
open_websocket_daemon_transport: async (args) => {
|
||||
return await openWebSocketTransportSession(args as unknown as WebSocketTransportTarget);
|
||||
},
|
||||
send_local_daemon_transport_message: async (args) => {
|
||||
await sendLocalTransportMessage(
|
||||
args as { sessionId: string; text?: string; binaryBase64?: string },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,13 @@ interface TransportTarget {
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface WebSocketTransportTarget {
|
||||
transportType: "websocket";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
interface TransportEventPayload {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
@@ -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<string, Session>();
|
||||
@@ -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<string, string> = {};
|
||||
for (const [name, value] of Object.entries(target.headers ?? {})) {
|
||||
if (!HTTP_HEADER_NAME_PATTERN.test(name) || typeof value !== "string" || /[\r\n]/.test(value)) {
|
||||
throw new Error("Invalid WebSocket transport header.");
|
||||
}
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
transportType: "websocket",
|
||||
url: parsed.toString(),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(target.protocols?.length ? { protocols: [...target.protocols] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeTransportMessage(input: { text?: string; binaryBase64?: string }): string | Buffer {
|
||||
if (typeof input.text === "string") {
|
||||
return input.text;
|
||||
@@ -65,11 +95,28 @@ function decodeTransportMessage(input: { text?: string; binaryBase64?: string })
|
||||
}
|
||||
|
||||
export function openLocalTransportSession(target: TransportTarget): Promise<string> {
|
||||
return openTransportSession(buildLocalWebSocketUrl(target), describeTransportTarget(target));
|
||||
}
|
||||
|
||||
export function openWebSocketTransportSession(target: WebSocketTransportTarget): Promise<string> {
|
||||
const normalized = normalizeWebSocketTarget(target);
|
||||
return openTransportSession(normalized.url, "remote daemon", {
|
||||
headers: normalized.headers,
|
||||
protocols: normalized.protocols,
|
||||
});
|
||||
}
|
||||
|
||||
function openTransportSession(
|
||||
url: string,
|
||||
targetDescription: string,
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
): Promise<string> {
|
||||
const sessionId = `local-session-${++nextSessionId}`;
|
||||
const url = buildLocalWebSocketUrl(target);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = 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<stri
|
||||
sessions.delete(sessionId);
|
||||
|
||||
if (!openSettled) {
|
||||
finalizeOpenFailure(
|
||||
`${describeTransportTarget(target)} closed before the session became ready.`,
|
||||
);
|
||||
finalizeOpenFailure(`${targetDescription} closed before the session became ready.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,9 +184,7 @@ export function openLocalTransportSession(target: TransportTarget): Promise<stri
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
if (!openSettled) {
|
||||
finalizeOpenFailure(
|
||||
`Failed to connect to ${describeTransportTarget(target)}: ${err.message}`,
|
||||
);
|
||||
finalizeOpenFailure(`Failed to connect to ${targetDescription}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof DirectTcpHostConnectionSchema>;
|
||||
|
||||
Reference in New Issue
Block a user