Make relay opt-in when pairing devices (#2706)

* feat: make relay opt-in for device pairing

* fix(relay): keep pairing state authoritative

* fix(relay): refresh pairing state across transports

* fix(onboard): honor explicit relay opt-out

* fix(relay): surface transport startup failures

* fix(pairing): gate offers for older daemons

* test(cli): rely on real pairing transport coverage

* fix(cli): report live relay without a pid file

* fix(relay): synchronize config during daemon startup

* fix(relay): preserve mutability for ignored overrides

* fix(cli): use reachable daemon for pairing

* fix(app): redirect invalid pair device routes

* fix(pairing): support remote host device pairing

Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals.

* fix(pairing): validate pairing destinations

Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state.

* fix(pairing): render QR codes across platforms

Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack.

* fix(cli): pair from persisted relay state

Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
This commit is contained in:
Mohamed Boudra
2026-08-01 04:35:06 +08:00
committed by GitHub
parent 3778d0ad7b
commit d2de309fc3
73 changed files with 2163 additions and 543 deletions
@@ -24,6 +24,8 @@ type OutdatedDaemonMessage = OutdatedDaemonReadyMessage | OutdatedDaemonErrorMes
export async function startOutdatedDaemon(options?: {
desktopManaged?: boolean;
daemonStatusRpcCapability?: boolean;
relayConfigCapability?: boolean;
}): Promise<OutdatedDaemon> {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -37,6 +39,8 @@ export async function startOutdatedDaemon(options?: {
...process.env,
E2E_METRO_PORT: metroPort,
E2E_DESKTOP_MANAGED: options?.desktopManaged === true ? "1" : "0",
E2E_DAEMON_STATUS_RPC_CAPABILITY: options?.daemonStatusRpcCapability === false ? "0" : "1",
E2E_RELAY_CONFIG_CAPABILITY: options?.relayConfigCapability === false ? "0" : "1",
},
execArgv: ["--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
@@ -1,6 +1,6 @@
import { once } from "node:events";
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -10,10 +10,18 @@ export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
getPid(): number | undefined;
restart(): Promise<void>;
close(): Promise<void>;
}
interface IsolatedHostDaemonOptions {
mutableRelay?: {
enabled: boolean;
endpoint?: string;
};
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -76,7 +84,10 @@ async function stopProcess(child: ChildProcess): Promise<void> {
}
}
export async function startIsolatedHostDaemon(serverId: string): Promise<IsolatedHostDaemon> {
export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
): Promise<IsolatedHostDaemon> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
@@ -85,6 +96,26 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
if (options.mutableRelay) {
const endpoint =
options.mutableRelay.endpoint ??
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
version: 1,
daemon: {
relay: {
enabled: options.mutableRelay.enabled,
endpoint,
publicEndpoint: endpoint,
useTls: false,
publicUseTls: false,
},
},
})}\n`,
);
}
const serverDir = path.resolve(__dirname, "../../../server");
const tsxBin = execSync("which tsx").toString().trim();
const spawnDaemon = async (): Promise<ChildProcess> => {
@@ -96,7 +127,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
PASEO_SERVER_ID: serverId,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_RELAY_ENABLED: "0",
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
}),
@@ -135,6 +166,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
serverId,
port,
paseoHome,
getPid: () => child.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);
+218
View File
@@ -0,0 +1,218 @@
import { expect, type Page } from "@playwright/test";
import type { IsolatedHostDaemon } from "./isolated-host-daemon";
import type { OutdatedDaemon } from "./daemon-update";
import { openSettings, gotoAppShell } from "./app";
import {
openSettingsHost,
openSettingsHostSection,
seedSavedSettingsHosts,
selectSettingsHost,
} from "./settings";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
interface PairingHostInput {
serverId: string;
label: 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(
page: Page,
daemon: IsolatedHostDaemon | OutdatedDaemon,
additionalHosts: PairingHostInput[] = [],
): Promise<void> {
await seedSavedSettingsHosts(page, [
{
serverId: daemon.serverId,
label: "Local pairing host",
endpoint: "port" in daemon ? `127.0.0.1:${daemon.port}` : daemon.endpoint,
},
...additionalHosts,
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, daemon.serverId);
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
await openSettingsHostSection(page, daemon.serverId, "pair-device");
await expect(page.getByTestId("host-page-pair-device-row")).toBeVisible();
}
export async function openPairDeviceModal(page: Page): Promise<void> {
await page.getByRole("button", { name: /Pair a device/ }).click();
await expect(page.getByTestId("host-page-pair-device-card")).toBeVisible();
}
export async function expectRelayConsent(page: Page): Promise<void> {
const modal = page.getByTestId("host-page-pair-device-card");
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
await expect(modal.getByText(/end-to-end encrypted/)).toBeVisible();
await expect(modal.getByRole("link", { name: "Read how Paseo relay works" })).toBeVisible();
await expect(modal.getByText(/TCP, Tailscale, or another VPN/)).toBeVisible();
await expect(modal.getByRole("img", { name: "Pairing QR code" })).toHaveCount(0);
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
await expect(modal.getByRole("button", { name: "Copy", exact: true })).toHaveCount(0);
}
export async function declineRelay(page: Page): Promise<void> {
await page.getByRole("button", { name: "Not now", exact: true }).click();
await expect(page.getByTestId("host-page-pair-device-card")).toHaveCount(0);
}
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
await enableButton.click();
await expect(page.getByRole("button", { name: "Enabling...", exact: true })).toBeDisabled();
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
await expect(page.getByText("Enable relay?", { exact: true })).toHaveCount(0);
}
export async function expectPairingOffer(page: Page): Promise<void> {
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
}
export async function closePairDeviceModal(page: Page): Promise<void> {
await page
.getByTestId("host-page-pair-device-card")
.getByRole("button", { name: "Close", exact: true })
.click();
await expect(page.getByTestId("host-page-pair-device-card")).toHaveCount(0);
}
export async function reloadAndOpenPairDevice(page: Page): Promise<void> {
await page.evaluate(() => {
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
if (!nonce) throw new Error("Expected e2e seed nonce");
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
});
await page.reload();
await openPairDeviceModal(page);
}
export async function enableRelayAndExpectFailure(
page: Page,
expectedError = "Relay is controlled by a daemon launch override",
): Promise<void> {
await page.getByRole("button", { name: "Enable relay", exact: true }).click();
const modal = page.getByTestId("host-page-pair-device-card");
await expect(modal.getByRole("alert")).toContainText(expectedError);
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeEnabled();
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
}
export async function retryRelayAndExpectFailure(
page: Page,
expectedError = "Relay is controlled by a daemon launch override",
): Promise<void> {
const modal = page.getByTestId("host-page-pair-device-card");
await modal.getByRole("button", { name: "Retry", exact: true }).click();
await expect(modal.getByRole("alert")).toContainText(expectedError);
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeEnabled();
}
export async function openPairDeviceFromHome(page: Page): Promise<void> {
await page.evaluate(() => {
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
if (!nonce) throw new Error("Expected e2e seed nonce");
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
});
await page.goto("/open-project");
await page.getByTestId("open-project-pair-device").click();
await expect(page.getByTestId("open-project-pair-device-modal")).toBeVisible();
}
export async function expectRelayUpdateRequired(page: Page): Promise<void> {
const modal = page.getByTestId("host-page-pair-device-card");
await expect(
modal.getByText("Update the host to enable relay from Paseo Desktop."),
).toBeVisible();
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
}
export function observePairingOfferRequests(page: Page, daemon: OutdatedDaemon): () => void {
let requestCount = 0;
const port = daemon.endpoint.slice(daemon.endpoint.lastIndexOf(":") + 1);
page.on("websocket", (socket) => {
if (!socket.url().includes(`:${port}`)) return;
socket.on("framesent", ({ payload }) => {
const message = typeof payload === "string" ? payload : payload.toString();
if (message.includes('"type":"daemon.get_pairing_offer.request"')) requestCount += 1;
});
});
return () => expect(requestCount).toBe(0);
}
export async function expectPairingDisconnected(page: Page): Promise<void> {
const modal = page.getByTestId("host-page-pair-device-card");
await expect(modal.getByRole("alert")).toContainText("Host is not connected");
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
export async function switchPairDeviceToHost(page: Page, serverId: string): Promise<void> {
await selectSettingsHost(page, serverId);
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "pair-device"));
await expect(page.getByTestId("host-page-pair-device-row")).toBeVisible();
}
export async function openRelaySecurityDocs(page: Page): Promise<void> {
await page.getByRole("link", { name: "Read how Paseo relay works" }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem("@paseo:e2e-opened-url")))
.toBe("https://paseo.sh/docs/security");
}
export function expectDaemonPidUnchanged(
before: number | undefined,
after: number | undefined,
): void {
expect(before).toBeDefined();
expect(after).toBe(before);
}
+2 -2
View File
@@ -34,6 +34,7 @@ export type SettingsSection = keyof typeof SECTION_LABELS;
type HostSection =
| "projects"
| "connections"
| "pair-device"
| "agents"
| "workspaces"
| "providers"
@@ -359,8 +360,7 @@ export async function expectProviderInstalledInSettings(
).toBeVisible();
}
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
export async function expectHostNoDaemonLifecycleRow(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
}