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
+9
View File
@@ -25,6 +25,7 @@ const test = base.extend<{
paseoE2ESetup: void;
outdatedDaemon: OutdatedDaemon;
desktopManagedOutdatedDaemon: OutdatedDaemon;
relayConfigOutdatedDaemon: OutdatedDaemon;
projectPickerFixture: TrackedProjectPickerFixture;
withWorkspace: WithWorkspace;
}>({
@@ -129,6 +130,14 @@ const test = base.extend<{
await provide(daemon);
await daemon.close();
},
relayConfigOutdatedDaemon: async ({}, provide) => {
const daemon = await startOutdatedDaemon({
daemonStatusRpcCapability: false,
relayConfigCapability: false,
});
await provide(daemon);
await daemon.close();
},
projectPickerFixture: async ({}, provide) => {
const resource = await createProjectPickerFixture();
const { fixture } = resource;
+1
View File
@@ -780,6 +780,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENABLED: "true",
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
@@ -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);
}
+192
View File
@@ -0,0 +1,192 @@
import { expect, test } from "./fixtures";
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectDaemonClient } from "./helpers/daemon-client-loader";
import {
closePairDeviceModal,
declineRelay,
enableRelayAndExpectFailure,
enableRelayAndExpectOffer,
expectDaemonPidUnchanged,
expectPairingOffer,
expectPairingDisconnected,
expectRelayUpdateRequired,
expectRelayConsent,
openPairDeviceModal,
openPairDeviceFromHome,
openRelaySecurityDocs,
observePairingOfferRequests,
prepareLocalPairingHost,
preparePairingHost,
reloadAndOpenPairDevice,
retryRelayAndExpectFailure,
switchPairDeviceToHost,
} from "./helpers/pair-device";
interface RelayConfigDaemonClient {
close(): Promise<void>;
connect(): Promise<void>;
patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>;
}
test.describe("local device relay pairing", () => {
let relayOffDaemon: IsolatedHostDaemon;
let relayEnableDaemon: IsolatedHostDaemon;
let relayOverrideDaemon: IsolatedHostDaemon;
let relayEnabledDaemon: IsolatedHostDaemon;
let invalidRelayDaemon: IsolatedHostDaemon;
test.beforeAll(async () => {
[
relayOffDaemon,
relayEnableDaemon,
relayOverrideDaemon,
relayEnabledDaemon,
invalidRelayDaemon,
] = await Promise.all([
startIsolatedHostDaemon("pair-device-relay-off", {
mutableRelay: { enabled: false },
}),
startIsolatedHostDaemon("pair-device-relay-enable", {
mutableRelay: { enabled: false },
}),
startIsolatedHostDaemon("pair-device-relay-override"),
startIsolatedHostDaemon("pair-device-relay-enabled", {
mutableRelay: { enabled: true },
}),
startIsolatedHostDaemon("pair-device-relay-invalid", {
mutableRelay: { enabled: false, endpoint: "invalid-endpoint" },
}),
]);
});
test.afterAll(async () => {
await Promise.all([
relayOffDaemon.close(),
relayEnableDaemon.close(),
relayOverrideDaemon.close(),
relayEnabledDaemon.close(),
invalidRelayDaemon.close(),
]);
});
test("asks for consent and creates no QR when declined", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await declineRelay(page);
await openPairDeviceModal(page);
await expectRelayConsent(page);
});
test("opens relay security documentation through the desktop opener", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceModal(page);
await openRelaySecurityDocs(page);
});
test("opens the same relay consent dialog from the home screen", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon);
await openPairDeviceFromHome(page);
const modal = page.getByTestId("open-project-pair-device-modal");
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toBeVisible();
});
test("opens relay consent in browser web", async ({ page }) => {
await preparePairingHost(page, relayOffDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
});
test("shows an actionable error when the daemon disconnects", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("pair-device-disconnect", {
mutableRelay: { enabled: false },
});
try {
await prepareLocalPairingHost(page, daemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await daemon.close();
await expectPairingDisconnected(page);
} finally {
await daemon.close();
}
});
test("pairs a device through the selected remote host", async ({ page }) => {
await prepareLocalPairingHost(page, relayOffDaemon, [
{
serverId: relayEnabledDaemon.serverId,
label: "Remote pairing host",
endpoint: `127.0.0.1:${relayEnabledDaemon.port}`,
},
]);
await switchPairDeviceToHost(page, relayEnabledDaemon.serverId);
await openPairDeviceModal(page);
await expectPairingOffer(page);
});
test("enables relay live and keeps the saved offer after reload", async ({ page }) => {
const daemonPid = relayEnableDaemon.getPid();
await prepareLocalPairingHost(page, relayEnableDaemon);
await openPairDeviceModal(page);
await enableRelayAndExpectOffer(page);
expectDaemonPidUnchanged(daemonPid, relayEnableDaemon.getPid());
await closePairDeviceModal(page);
await reloadAndOpenPairDevice(page);
await expectPairingOffer(page);
});
test("keeps consent actionable when a launch override rejects enable", async ({ page }) => {
await prepareLocalPairingHost(page, relayOverrideDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await enableRelayAndExpectFailure(page);
await retryRelayAndExpectFailure(page);
});
test("keeps consent actionable when relay transport startup fails", async ({ page }) => {
await prepareLocalPairingHost(page, invalidRelayDaemon);
await openPairDeviceModal(page);
await expectRelayConsent(page);
await enableRelayAndExpectFailure(page, "Invalid host:port");
await retryRelayAndExpectFailure(page, "Invalid host:port");
});
test("shows an offer immediately when relay is already enabled", async ({ page }) => {
await prepareLocalPairingHost(page, relayEnabledDaemon);
await openPairDeviceModal(page);
await expectPairingOffer(page);
});
test("refreshes the mounted offer when another client disables relay", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("pair-device-live-config-push", {
mutableRelay: { enabled: true },
});
const client = await connectDaemonClient<RelayConfigDaemonClient>({
clientIdPrefix: "pair-device-live-config-push",
port: daemon.port,
});
try {
await prepareLocalPairingHost(page, daemon);
await openPairDeviceModal(page);
await expectPairingOffer(page);
await client.patchDaemonConfig({ relay: { enabled: false } });
await expectRelayConsent(page);
} finally {
await client.close();
await daemon.close();
}
});
test("asks for a host update when live relay config is unsupported", async ({
page,
relayConfigOutdatedDaemon,
}) => {
const expectNoUnsupportedRequest = observePairingOfferRequests(page, relayConfigOutdatedDaemon);
await prepareLocalPairingHost(page, relayConfigOutdatedDaemon);
await openPairDeviceModal(page);
await expectRelayUpdateRequired(page);
expectNoUnsupportedRequest();
});
});
+4 -4
View File
@@ -14,7 +14,7 @@ import {
expectHostInjectMcpCard,
expectHostActionCards,
expectHostProvidersCard,
expectHostNoLocalOnlyRows,
expectHostNoDaemonLifecycleRow,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
expectLocalHostEntryFirst,
@@ -126,7 +126,7 @@ test.describe("Settings host page", () => {
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
});
test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
test("host section does not render daemon lifecycle controls for a remote daemon", async ({
page,
}) => {
const serverId = getServerId();
@@ -136,8 +136,8 @@ test.describe("Settings host page", () => {
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page);
// TODO: add a local-daemon fixture for positive daemon lifecycle coverage.
await expectHostNoDaemonLifecycleRow(page);
});
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
+3
View File
@@ -0,0 +1,3 @@
export function daemonPairingOfferQueryKey(serverId: string) {
return ["daemon-pairing-offer", serverId] as const;
}
@@ -4,6 +4,7 @@ import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/prot
import { checkoutDiffQueryKey } from "@/git/query-keys";
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
import { providersSnapshotQueryKey } from "@/data/providers-snapshot";
import {
checkoutDiffPushRoute,
@@ -34,6 +35,7 @@ type RouterHandler = (message: RouterMessage) => void;
type RouterClient = Parameters<typeof mountServerDataPushRouter>[0]["client"];
const daemonConfig: MutableDaemonConfig = {
relay: { enabled: false },
mcp: { injectIntoAgents: true },
browserTools: { enabled: false },
providers: {},
@@ -145,6 +147,8 @@ describe("server data push router", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const pairingOfferKey = daemonPairingOfferQueryKey(serverId);
queryClient.setQueryData(pairingOfferKey, { relayEnabled: true, url: "https://pairing" });
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
fake.emit(providerUpdate("2026-01-01T00:00:00.000Z"));
@@ -159,6 +163,7 @@ describe("server data push router", () => {
requestId: "providers_snapshot_update",
});
expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig);
expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true);
unmount();
fake.emit(providerUpdate("2026-01-01T00:00:01.000Z"));
@@ -502,12 +507,14 @@ describe("server data push router", () => {
const otherServerId = "server-2";
const providerKey = providersSnapshotQueryKey(serverId);
const daemonConfigKey = daemonConfigQueryKey(serverId);
const pairingOfferKey = daemonPairingOfferQueryKey(serverId);
const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false);
const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a");
const otherProviderKey = providersSnapshotQueryKey(otherServerId);
queryClient.setQueryData(providerKey, { entries: [], generatedAt: "now", requestId: "p" });
queryClient.setQueryData(daemonConfigKey, daemonConfig);
queryClient.setQueryData(pairingOfferKey, { relayEnabled: false, url: "" });
queryClient.setQueryData(diffKey, { cwd: "/repo", files: [], error: null, requestId: "d" });
queryClient.setQueryData(terminalKey, { cwd: "/repo", terminals: [], requestId: "t" });
queryClient.setQueryData(otherProviderKey, {
@@ -520,6 +527,7 @@ describe("server data push router", () => {
expect(queryClient.getQueryState(providerKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(daemonConfigKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(diffKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(terminalKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherProviderKey)?.isInvalidated).toBe(false);
+10
View File
@@ -7,6 +7,7 @@ import type {
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
import { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
type ProvidersSnapshotUpdateMessage = Extract<
@@ -105,6 +106,12 @@ const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [
void queryClient.invalidateQueries({ queryKey: daemonConfigQueryKey(serverId) });
},
},
{
domain: "daemonPairingOffer",
invalidate: ({ queryClient, serverId }) => {
void queryClient.invalidateQueries({ queryKey: daemonPairingOfferQueryKey(serverId) });
},
},
{
domain: "checkoutDiff",
invalidate: ({ queryClient, serverId }) => {
@@ -403,6 +410,9 @@ function applyDaemonConfigStatus(input: {
daemonConfigQueryKey(input.serverId),
payload.config,
);
void input.queryClient.invalidateQueries({
queryKey: daemonPairingOfferQueryKey(input.serverId),
});
}
function applyCheckoutDiffUpdate(input: {
@@ -4,14 +4,15 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
export interface PairDeviceModalProps {
serverId: string;
visible: boolean;
onClose: () => void;
testID?: string;
}
const SNAP_POINTS: string[] = ["82%", "94%"];
const SNAP_POINTS: string[] = ["72%", "92%"];
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
export function PairDeviceModal({ serverId, visible, onClose, testID }: PairDeviceModalProps) {
const { t } = useTranslation();
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.host.pairDevices.rowTitle") }),
@@ -24,10 +25,10 @@ export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalPro
visible={visible}
onClose={onClose}
snapPoints={SNAP_POINTS}
desktopMaxWidth={640}
desktopMaxWidth={480}
testID={testID}
>
<PairDeviceSection />
<PairDeviceSection serverId={serverId} onClose={onClose} />
</AdaptiveModalSheet>
);
}
@@ -1,78 +1,86 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Image, Text, TextInput, View } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { Text, TextInput, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useQuery } from "@tanstack/react-query";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { RotateCw, Copy, Check } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { SvgXml } from "react-native-svg";
import { useMutation } from "@tanstack/react-query";
import { Check, Copy, Network, RotateCw, ShieldCheck } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useState } from "react";
import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useFetchQuery } from "@/data/query";
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedShieldCheck = withUnistyles(ShieldCheck);
const ThemedNetwork = withUnistyles(Network);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const accentBrightColorMapping = (theme: Theme) => ({ color: theme.colors.accentBright });
type PairingViewState =
| { tag: "loading" }
| { tag: "error"; message: string }
| { tag: "unavailable"; message: string }
| { tag: "ready"; url: string };
function resolvePairingViewState(args: {
isPending: boolean;
isError: boolean;
error: unknown;
data: { url?: string | null; relayEnabled?: boolean } | undefined;
labels: {
failedToLoadOffer: string;
relayDisabled: string;
unavailable: string;
};
}): PairingViewState {
if (args.isPending) return { tag: "loading" };
if (args.isError) {
const message =
args.error instanceof Error ? args.error.message : args.labels.failedToLoadOffer;
return { tag: "error", message };
}
if (!args.data?.url) {
const message =
args.data?.relayEnabled === false ? args.labels.relayDisabled : args.labels.unavailable;
return { tag: "unavailable", message };
}
return { tag: "ready", url: args.data.url };
export interface PairDeviceSectionProps {
serverId: string;
onClose: () => void;
}
export function PairDeviceSection() {
const { theme } = useUnistyles();
export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps) {
const { t } = useTranslation();
const showSection = shouldUseDesktopDaemon();
const client = useHostRuntimeClient(serverId);
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
const isConnected = runtimeSnapshot?.connectionStatus === "online";
const isDisconnected =
runtimeSnapshot?.connectionStatus === "offline" ||
runtimeSnapshot?.connectionStatus === "error";
const { patchConfig } = useDaemonConfig(serverId);
const [copied, setCopied] = useState(false);
const serverFeatures = client?.getLastServerInfoMessage()?.features;
const supportsPairingRpc = serverFeatures?.daemonStatusRpc === true;
const canConfigureRelay = supportsPairingRpc && serverFeatures?.relayConfig === true;
const pairingQuery = useQuery({
queryKey: ["desktop-daemon-pairing"],
queryFn: getDesktopDaemonPairing,
enabled: showSection,
staleTime: 5 * 60 * 1000,
const pairingQuery = useFetchQuery({
queryKey: daemonPairingOfferQueryKey(serverId),
queryFn: async () => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
return client.getDaemonPairingOffer();
},
enabled: supportsPairingRpc && Boolean(client && isConnected),
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
retry: 1,
});
const qrQuery = useQuery({
queryKey: ["desktop-daemon-pairing-qr", pairingQuery.data?.url],
const enableRelay = useMutation({
mutationFn: async () => {
if (client?.getLastServerInfoMessage()?.features?.relayConfig !== true) {
throw new Error(t("pairing.device.updateRequired"));
}
const config = await patchConfig({ relay: { enabled: true } });
if (!config) throw new Error(t("workspace.terminal.hostDisconnected"));
return pairingQuery.refetch();
},
});
const qrQuery = useFetchQuery({
queryKey: ["daemon-pairing-offer-qr", pairingQuery.data?.url],
queryFn: () =>
QRCode.toDataURL(pairingQuery.data!.url!, {
QRCode.toString(pairingQuery.data?.url ?? "", {
type: "svg",
errorCorrectionLevel: "M",
margin: 1,
width: 480,
}),
enabled: !!pairingQuery.data?.url,
staleTime: Infinity,
enabled: Boolean(pairingQuery.data?.url),
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
});
const handleCopyLink = useCallback(async () => {
@@ -81,203 +89,271 @@ export function PairDeviceSection() {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [pairingQuery.data?.url]);
const handleRefetch = useCallback(() => {
void pairingQuery.refetch();
}, [pairingQuery]);
const handleCopyPress = useCallback(() => {
void handleCopyLink();
}, [handleCopyLink]);
const handleRetry = useCallback(() => {
void pairingQuery.refetch();
}, [pairingQuery]);
const handleEnableRelay = useCallback(() => {
enableRelay.mutate();
}, [enableRelay]);
const qrImageSource = useMemo(
() => (qrQuery.data ? { uri: qrQuery.data } : null),
[qrQuery.data],
);
const retryIcon = useMemo(
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
const copyButtonIcon = useMemo(
() =>
copied ? (
<Check size={theme.iconSize.sm} color={theme.colors.accent} />
) : (
<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />
),
[copied, theme.iconSize.sm, theme.colors.accent, theme.colors.foreground],
);
const bodyLabels = useMemo(
() => ({
loadingOffer: t("pairing.device.loadingOffer"),
hint: t("pairing.device.hint"),
qrUnavailable: t("pairing.device.qrUnavailable"),
retry: t("pairing.device.retry"),
copy: t("pairing.device.copy"),
copied: t("pairing.device.copied"),
}),
[t],
);
if (!showSection) return null;
const viewState = resolvePairingViewState({
isPending: pairingQuery.isPending,
isError: pairingQuery.isError,
error: pairingQuery.error,
data: pairingQuery.data,
labels: {
failedToLoadOffer: t("pairing.device.failedToLoadOffer"),
relayDisabled: t("pairing.device.relayDisabled"),
unavailable: t("pairing.device.unavailable"),
},
});
const qrSvg = useMemo(() => qrQuery.data ?? null, [qrQuery.data]);
return (
<View style={settingsStyles.section} testID="host-page-pair-device-card">
<View style={settingsStyles.card}>
<PairDeviceBody
viewState={viewState}
theme={theme}
retryIcon={retryIcon}
copyButtonIcon={copyButtonIcon}
qrImageSource={qrImageSource}
qrQuery={qrQuery}
copied={copied}
handleRefetch={handleRefetch}
handleCopyPress={handleCopyPress}
labels={bodyLabels}
/>
</View>
<View testID="pair-device-content">
<PairDeviceBody
isPending={supportsPairingRpc && pairingQuery.isPending}
isDisconnected={isDisconnected}
error={pairingQuery.error}
offer={pairingQuery.data}
canConfigureRelay={canConfigureRelay}
enablePending={enableRelay.isPending}
enableError={enableRelay.error}
qrSvg={qrSvg}
qrError={qrQuery.isError}
copied={copied}
onRetry={handleRetry}
onEnableRelay={handleEnableRelay}
onClose={onClose}
onCopy={handleCopyPress}
/>
</View>
);
}
interface PairDeviceBodyProps {
viewState: PairingViewState;
theme: { colors: { accent: string } };
retryIcon: React.ReactElement;
copyButtonIcon: React.ReactElement;
qrImageSource: { uri: string } | null;
qrQuery: { isError: boolean };
isPending: boolean;
isDisconnected: boolean;
error: Error | null;
offer: { relayEnabled: boolean; url: string } | undefined;
canConfigureRelay: boolean;
enablePending: boolean;
enableError: Error | null;
qrSvg: string | null;
qrError: boolean;
copied: boolean;
handleRefetch: () => void;
handleCopyPress: () => void;
labels: {
loadingOffer: string;
hint: string;
qrUnavailable: string;
retry: string;
copy: string;
copied: string;
};
onRetry: () => void;
onEnableRelay: () => void;
onClose: () => void;
onCopy: () => void;
}
function PairDeviceBody(props: PairDeviceBodyProps) {
const {
viewState,
theme,
retryIcon,
copyButtonIcon,
qrImageSource,
qrQuery,
copied,
handleRefetch,
handleCopyPress,
labels,
} = props;
if (viewState.tag === "loading") {
const { t } = useTranslation();
if (props.isDisconnected) {
return (
<View style={styles.centered}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.hint}>{labels.loadingOffer}</Text>
</View>
<OfferLoadError message={t("workspace.terminal.hostDisconnected")} onRetry={props.onRetry} />
);
}
if (viewState.tag === "error" || viewState.tag === "unavailable") {
return (
<View style={styles.centered}>
<Text style={styles.hint}>{viewState.message}</Text>
<Button variant="outline" size="sm" leftIcon={retryIcon} onPress={handleRefetch}>
{labels.retry}
</Button>
</View>
);
if (props.isPending) {
return <Text style={styles.stateLine}>{t("pairing.device.loadingOffer")}</Text>;
}
if (props.error) {
return <OfferLoadError message={props.error.message} onRetry={props.onRetry} />;
}
if (!props.offer?.relayEnabled) {
return <RelayConsent {...props} />;
}
if (!props.offer.url) {
return <Text style={styles.stateLine}>{t("pairing.device.unavailable")}</Text>;
}
return <PairingOffer {...props} offer={props.offer} />;
}
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
const { t } = useTranslation();
return (
<View style={styles.content}>
<Text style={styles.hint}>{labels.hint}</Text>
<View style={styles.qrContainer}>
<PairDeviceQrContent
qrImageSource={qrImageSource}
qrQuery={qrQuery}
unavailableLabel={labels.qrUnavailable}
<Alert variant="error" description={message}>
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
{t("pairing.device.retry")}
</Button>
</Alert>
);
}
function RelayConsent(props: PairDeviceBodyProps) {
const { t } = useTranslation();
let enableButtonLabel = t("pairing.device.enableRelay");
if (props.enablePending) {
enableButtonLabel = t("pairing.device.enablingRelay");
} else if (props.enableError) {
enableButtonLabel = t("pairing.device.retry");
}
return (
<View style={styles.consent}>
<View style={styles.hero}>
<RelayHeroBadge />
<Text style={styles.consentTitle}>{t("pairing.device.enableTitle")}</Text>
<Text style={styles.consentDescription}>{t("pairing.device.enableDescription")}</Text>
<ExternalLink
href={RELAY_DOCS_URL}
label={t("pairing.device.relayDocs")}
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
/>
</View>
{props.enableError ? <Alert variant="error" description={props.enableError.message} /> : null}
{!props.canConfigureRelay ? (
<Alert variant="warning" description={t("pairing.device.updateRequired")} />
) : null}
<View style={styles.actions}>
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
{t("pairing.device.notNow")}
</Button>
{props.canConfigureRelay ? (
<Button
variant="default"
style={FLEX_ONE_STYLE}
loading={props.enablePending}
onPress={props.onEnableRelay}
>
{enableButtonLabel}
</Button>
) : null}
</View>
<View style={styles.directRow}>
<ThemedNetwork size={14} style={styles.directIcon} />
<Text style={styles.directHint}>{t("pairing.device.directConnectionHint")}</Text>
</View>
</View>
);
}
function RelayHeroBadge() {
return (
<View style={styles.heroBadge}>
<ThemedShieldCheck size={20} uniProps={accentBrightColorMapping} />
</View>
);
}
function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
const { t } = useTranslation();
return (
<View style={styles.offer}>
<Text style={styles.offerHint}>{t("pairing.device.hint")}</Text>
<View style={styles.qrTile}>
<PairingQr svg={props.qrSvg} isError={props.qrError} />
</View>
<View style={styles.linkRow}>
<View style={styles.inputWrapper}>
<TextInput
style={styles.linkInput}
value={viewState.url}
value={props.offer.url}
readOnly
selectTextOnFocus
selectionColor={theme.colors.accent}
accessibilityLabel={t("pairing.link.label")}
/>
</View>
<Button variant="outline" size="sm" leftIcon={copyButtonIcon} onPress={handleCopyPress}>
{copied ? labels.copied : labels.copy}
<Button
variant="outline"
size="sm"
leftIcon={props.copied ? Check : Copy}
onPress={props.onCopy}
>
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
</Button>
</View>
</View>
);
}
function PairDeviceQrContent(props: {
qrImageSource: { uri: string } | null;
qrQuery: { isError: boolean };
unavailableLabel: string;
}) {
if (props.qrImageSource) {
return <Image source={props.qrImageSource} style={styles.qrImage} resizeMode="contain" />;
function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
const { t } = useTranslation();
if (svg) {
return (
<SvgXml
xml={svg}
style={styles.qrImage}
accessibilityRole="image"
accessibilityLabel={t("pairing.device.qrAccessibility")}
/>
);
}
if (props.qrQuery.isError) {
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
if (isError) {
return <Text style={styles.hint}>{t("pairing.device.qrUnavailable")}</Text>;
}
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
}
const styles = StyleSheet.create((theme) => ({
centered: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[4],
},
content: {
gap: theme.spacing[3],
padding: theme.spacing[4],
},
hint: {
stateLine: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontSize: theme.fontSize.sm,
textAlign: "center",
paddingVertical: theme.spacing[6],
},
qrContainer: {
alignItems: "center",
justifyContent: "center",
alignSelf: "center",
width: 320,
height: 320,
borderRadius: theme.borderRadius.lg,
consent: {
gap: theme.spacing[4],
},
hero: {
alignItems: "flex-start",
gap: theme.spacing[2],
},
heroBadge: {
width: 48,
height: 48,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[2],
alignItems: "center",
justifyContent: "center",
marginBottom: theme.spacing[1],
},
consentTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
consentDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
},
directRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[4],
},
directIcon: {
color: theme.colors.foregroundMuted,
marginTop: 1, // optical: seats the glyph on the hint's first text line
},
directHint: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.5,
},
offer: {
gap: theme.spacing[4],
},
offerHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
qrTile: {
alignSelf: "center",
alignItems: "center",
justifyContent: "center",
width: 304,
maxWidth: "100%",
aspectRatio: 1,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.palette.white,
},
qrImage: {
width: "100%",
@@ -290,17 +366,22 @@ const styles = StyleSheet.create((theme) => ({
},
inputWrapper: {
flex: 1,
borderRadius: theme.borderRadius.md,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
backgroundColor: theme.colors.surface2,
overflow: "hidden",
},
linkInput: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.xs,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
outlineStyle: "none",
} as object,
hint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));
@@ -40,12 +40,6 @@ export interface DesktopAppLogs {
contents: string;
}
export interface DesktopPairingOffer {
relayEnabled: boolean;
url: string | null;
qr: string | null;
}
export interface LocalTransportTarget {
[key: string]: unknown;
transportType: "socket" | "pipe";
@@ -119,17 +113,6 @@ function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
};
}
function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
if (!isRecord(raw)) {
throw new Error("Unexpected desktop daemon pairing response.");
}
return {
relayEnabled: raw.relayEnabled === true,
url: toStringOrNull(raw.url),
qr: toStringOrNull(raw.qr),
};
}
export function shouldUseDesktopDaemon(): boolean {
return isElectronRuntime();
}
@@ -167,10 +150,6 @@ export async function getDesktopAppLogs(): Promise<DesktopAppLogs> {
};
}
export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
}
export async function getCliDaemonStatus(): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
if (typeof raw !== "string") {
+12
View File
@@ -1422,9 +1422,21 @@ export const ar: TranslationResources = {
loadingOffer: "جارٍ تحميل عرض الإقران...",
failedToLoadOffer: "فشل تحميل عرض الاقتران.",
relayDisabled: "لم يتم تمكين التتابع. تمكين التتابع لإقران جهاز.",
enableTitle: "تمكين التتابع؟",
enableDescription:
"يتيح التتابع لهذا الجهاز الاتصال من أي مكان. حركة مرور الإقران مشفرة من طرف إلى طرف.",
relayDocs: "كيفية عمل التتابع",
relayDocsAccessibility: "اقرأ كيفية عمل تتابع Paseo",
enableRelay: "تمكين التتابع",
enablingRelay: "جارٍ التمكين...",
notNow: "ليس الآن",
directConnectionHint:
"بدون التتابع، اتصل مباشرة عبر TCP أو Tailscale أو شبكة VPN أخرى. لن يتم إنشاء رمز QR.",
updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.",
unavailable: "عرض الاقتران غير متاح.",
hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.",
qrUnavailable: "رمز QR غير متاح.",
qrAccessibility: "رمز QR للإقران",
retry: "أعد المحاولة",
copy: "ينسخ",
copied: "منقول",
+12
View File
@@ -1433,9 +1433,21 @@ export const en = {
loadingOffer: "Loading pairing offer...",
failedToLoadOffer: "Failed to load pairing offer.",
relayDisabled: "Relay is not enabled. Enable relay to pair a device.",
enableTitle: "Enable relay?",
enableDescription:
"Relay lets this device connect from anywhere. Pairing traffic is end-to-end encrypted.",
relayDocs: "How relay works",
relayDocsAccessibility: "Read how Paseo relay works",
enableRelay: "Enable relay",
enablingRelay: "Enabling...",
notNow: "Not now",
directConnectionHint:
"Without relay, connect directly over TCP, Tailscale, or another VPN. No QR code is created.",
updateRequired: "Update the host to enable relay from Paseo Desktop.",
unavailable: "Pairing offer unavailable.",
hint: "Scan this QR code with Paseo on your phone, or copy the link below.",
qrUnavailable: "QR code unavailable.",
qrAccessibility: "Pairing QR code",
retry: "Retry",
copy: "Copy",
copied: "Copied",
+12
View File
@@ -1465,9 +1465,21 @@ export const es: TranslationResources = {
loadingOffer: "Cargando oferta de maridaje...",
failedToLoadOffer: "No se pudo cargar la oferta de emparejamiento.",
relayDisabled: "El relé no está habilitado. Habilite el relé para emparejar un dispositivo.",
enableTitle: "¿Habilitar el relé?",
enableDescription:
"El relé permite conectar este dispositivo desde cualquier lugar. El tráfico de emparejamiento está cifrado de extremo a extremo.",
relayDocs: "Cómo funciona el relé",
relayDocsAccessibility: "Leer cómo funciona el relé de Paseo",
enableRelay: "Habilitar relé",
enablingRelay: "Habilitando...",
notNow: "Ahora no",
directConnectionHint:
"Sin relé, conéctese directamente por TCP, Tailscale u otra VPN. No se crea ningún código QR.",
updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.",
unavailable: "Oferta de maridaje no disponible.",
hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.",
qrUnavailable: "CódigoQRno disponible.",
qrAccessibility: "Código QR de emparejamiento",
retry: "Rever",
copy: "Copiar",
copied: "Copiado",
+12
View File
@@ -1469,9 +1469,21 @@ export const fr: TranslationResources = {
loadingOffer: "Chargement de l'offre d'association...",
failedToLoadOffer: "Échec du chargement de l'offre d'association.",
relayDisabled: "Le relais n'est pas activé. Activer le relais pour coupler un appareil.",
enableTitle: "Activer le relais ?",
enableDescription:
"Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic de couplage est chiffré de bout en bout.",
relayDocs: "Fonctionnement du relais",
relayDocsAccessibility: "Lire comment fonctionne le relais Paseo",
enableRelay: "Activer le relais",
enablingRelay: "Activation...",
notNow: "Pas maintenant",
directConnectionHint:
"Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.",
updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.",
unavailable: "Offre de jumelage indisponible.",
hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.",
qrUnavailable: "CodeQRindisponible.",
qrAccessibility: "Code QR de couplage",
retry: "Réessayer",
copy: "Copie",
copied: "Copié",
+12
View File
@@ -1438,9 +1438,21 @@ export const ja: TranslationResources = {
failedToLoadOffer: "ペアリングオファーの読み込みに失敗しました。",
relayDisabled:
"リレーが有効になっていません。デバイスをペアリングするにはリレーを有効にしてください。",
enableTitle: "リレーを有効にしますか?",
enableDescription:
"リレーを使うと、このデバイスからどこでも接続できます。ペアリング通信はエンドツーエンドで暗号化されます。",
relayDocs: "リレーの仕組み",
relayDocsAccessibility: "Paseo リレーの仕組みを読む",
enableRelay: "リレーを有効にする",
enablingRelay: "有効化中...",
notNow: "今はしない",
directConnectionHint:
"リレーを使わない場合は、TCP、Tailscale、または別の VPN で直接接続してください。QR コードは作成されません。",
updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。",
unavailable: "ペアリングオファーが利用できません。",
hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。",
qrUnavailable: "QRコードが利用できません。",
qrAccessibility: "ペアリング QR コード",
retry: "再試行",
copy: "コピー",
copied: "コピーしました",
+12
View File
@@ -1451,9 +1451,21 @@ export const ptBR: TranslationResources = {
loadingOffer: "Carregando oferta de pareamento...",
failedToLoadOffer: "Falha ao carregar oferta de pareamento.",
relayDisabled: "Relay não está ativado. Ative o relay para parear um dispositivo.",
enableTitle: "Ativar o relay?",
enableDescription:
"O relay permite conectar este dispositivo de qualquer lugar. O tráfego de pareamento é criptografado de ponta a ponta.",
relayDocs: "Como o relay funciona",
relayDocsAccessibility: "Leia como o relay do Paseo funciona",
enableRelay: "Ativar relay",
enablingRelay: "Ativando...",
notNow: "Agora não",
directConnectionHint:
"Sem relay, conecte diretamente por TCP, Tailscale ou outra VPN. Nenhum código QR é criado.",
updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.",
unavailable: "Oferta de pareamento indisponível.",
hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.",
qrUnavailable: "QR code indisponível.",
qrAccessibility: "Código QR de pareamento",
retry: "Tentar novamente",
copy: "Copiar",
copied: "Copiado",
+12
View File
@@ -1456,9 +1456,21 @@ export const ru: TranslationResources = {
loadingOffer: "Загрузка предложения по сопряжению...",
failedToLoadOffer: "Не удалось загрузить предложение сопряжения.",
relayDisabled: "Реле не включено. Включите реле для сопряжения устройства.",
enableTitle: "Включить реле?",
enableDescription:
"Реле позволяет подключаться с этого устройства откуда угодно. Трафик сопряжения защищён сквозным шифрованием.",
relayDocs: "Как работает реле",
relayDocsAccessibility: "Прочитать, как работает реле Paseo",
enableRelay: "Включить реле",
enablingRelay: "Включение...",
notNow: "Не сейчас",
directConnectionHint:
"Без реле подключайтесь напрямую через TCP, Tailscale или другую VPN. QR-код не создаётся.",
updateRequired: "Обновите хост, чтобы включить реле из Paseo Desktop.",
unavailable: "Предложение по сопряжению недоступно.",
hint: "Отсканируйте этот код QR с помощью Paseo на своем телефоне или скопируйте ссылку ниже.",
qrUnavailable: "Код QR недоступен.",
qrAccessibility: "QR-код сопряжения",
retry: "Повторить попытку",
copy: "Копировать",
copied: "Скопировано",
+11
View File
@@ -1403,9 +1403,20 @@ export const zhCN: TranslationResources = {
loadingOffer: "正在加载配对 offer...",
failedToLoadOffer: "加载配对 offer 失败。",
relayDisabled: "Relay 未启用。启用 relay 后才能配对设备。",
enableTitle: "启用中继?",
enableDescription: "中继让此设备可以从任何地方连接。配对流量采用端到端加密。",
relayDocs: "中继如何工作",
relayDocsAccessibility: "阅读 Paseo 中继的工作原理",
enableRelay: "启用中继",
enablingRelay: "正在启用...",
notNow: "暂不",
directConnectionHint:
"不使用中继时,请通过 TCP、Tailscale 或其他 VPN 直接连接。不会生成二维码。",
updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。",
unavailable: "配对 offer 不可用。",
hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。",
qrUnavailable: "二维码不可用。",
qrAccessibility: "配对二维码",
retry: "重试",
copy: "复制",
copied: "已复制",
@@ -132,6 +132,7 @@ export function OpenProjectScreen() {
<CommunityLinks />
</View>
<PairDeviceModal
serverId={localServerId ?? ""}
visible={isPairDeviceOpen}
onClose={handleClosePairDevice}
testID="open-project-pair-device-modal"
+6 -2
View File
@@ -34,6 +34,7 @@ import {
FolderGit2,
SquareTerminal,
Code2,
Smartphone,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -95,6 +96,7 @@ import {
} from "@/i18n/locales";
import {
HostConnectionsPage,
HostPairDevicePage,
HostAgentsPage,
HostSettingsPage,
HostProvidersPage,
@@ -167,6 +169,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
{ id: "projects", labelKey: "settings.hostSections.projects", icon: FolderGit2 },
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
@@ -183,6 +186,8 @@ function renderHostSettingsContent(
return <ProjectsScreen serverId={view.serverId} />;
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "pair-device":
return <HostPairDevicePage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "workspaces":
@@ -1262,8 +1267,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
if (view.kind !== "host") {
return;
}
const section: HostSectionSlug = view.section;
const target = buildSettingsHostSectionRoute(serverId, section);
const target = buildSettingsHostSectionRoute(serverId, view.section);
if (isCompactLayout) {
router.push(target);
} else {
@@ -9,6 +9,7 @@ import {
function makeConfig(browserToolsEnabled = false): MutableDaemonConfig {
return {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: browserToolsEnabled },
providers: {},
@@ -237,9 +237,7 @@ function HostConnectionError({ serverId }: { serverId: string }) {
}
export function HostConnectionsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
@@ -249,15 +247,25 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) {
<View>
<HostConnectionError serverId={serverId} />
<ConnectionsSection host={host} />
{isLocalDaemon ? (
<SettingsSection title={t("settings.host.pairDevices.title")}>
<PairDeviceRow />
</SettingsSection>
) : null}
</View>
);
}
export function HostPairDevicePage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<SettingsSection title={t("settings.host.pairDevices.title")}>
<PairDeviceRow serverId={serverId} />
</SettingsSection>
);
}
export function HostAgentsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
@@ -1241,7 +1249,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
);
}
function PairDeviceRow() {
function PairDeviceRow({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -1265,6 +1273,7 @@ function PairDeviceRow() {
</Pressable>
<PairDeviceModal
serverId={serverId}
visible={isModalOpen}
onClose={handleClose}
testID="host-page-pair-device-card"
@@ -304,6 +304,7 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers,
@@ -246,6 +246,7 @@ describe("global routes", () => {
describe("host settings section slugs", () => {
it("keeps current host settings sections", () => {
expect(normalizeHostSectionSlug("connections")).toBe("connections");
expect(normalizeHostSectionSlug("pair-device")).toBe("pair-device");
expect(normalizeHostSectionSlug("agents")).toBe("agents");
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
expect(normalizeHostSectionSlug("projects")).toBe("projects");
+1
View File
@@ -509,6 +509,7 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
export const HOST_SECTION_SLUGS = [
"projects",
"connections",
"pair-device",
"agents",
"workspaces",
"providers",
+1
View File
@@ -139,6 +139,7 @@ export function createCli(): Command {
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.option("--port <port>", "Port for restarted daemon listen target")
.option("--relay", "Enable relay on restarted daemon")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
@@ -40,6 +40,7 @@ export function createDaemonCommand(): Command {
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.option("--port <port>", "Port for restarted daemon listen target")
.option("--relay", "Enable relay on restarted daemon")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
@@ -128,6 +128,9 @@ function envWithHome(home?: string): NodeJS.ProcessEnv {
function buildRunnerArgs(options: DaemonStartOptions): string[] {
const args: string[] = [];
if (options.relay === true) {
args.push("--relay");
}
if (options.relay === false) {
args.push("--no-relay");
}
@@ -0,0 +1,139 @@
import { describe, expect, test, vi } from "vitest";
import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js";
const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null };
const enabledOffer: PairingOffer = {
relayEnabled: true,
url: "https://app.paseo.sh/#offer=test",
qr: null,
};
interface RecordedPairCommandOutput extends PairCommandOutput {
stdout: string[];
stderr: string[];
successes: string[];
exitCode: number | undefined;
}
function createRecordedOutput(): RecordedPairCommandOutput {
return {
columns: 80,
stdout: [],
stderr: [],
successes: [],
exitCode: undefined,
writeStdout(message) {
this.stdout.push(message);
},
writeStderr(message) {
this.stderr.push(message);
},
setExitCode(code) {
this.exitCode = code;
},
success(message) {
this.successes.push(message);
},
};
}
describe("daemon pair workflow", () => {
test("interactive decline prints direct guidance and creates no pairing output", async () => {
const resolveOffer = vi.fn(async () => disabledOffer);
const confirmRelay = vi.fn(async () => false);
const printDirectGuidance = vi.fn();
const output = createRecordedOutput();
await runPairCommand(
{},
{ resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output },
);
expect(confirmRelay).toHaveBeenCalledOnce();
expect(printDirectGuidance).toHaveBeenCalledOnce();
expect(output.stderr.join("")).toContain("No pairing QR was created");
expect(output.exitCode).toBe(1);
});
test("interactive consent enables relay and prints the refreshed offer", async () => {
const resolveOffer = vi
.fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise<PairingOffer>>()
.mockResolvedValueOnce(disabledOffer)
.mockResolvedValueOnce(enabledOffer);
const output = createRecordedOutput();
await runPairCommand(
{},
{
resolveOffer,
confirmRelay: async () => true,
printDirectGuidance: vi.fn(),
isInteractive: () => true,
output,
},
);
expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true }));
expect(output.stdout.join("")).toContain(enabledOffer.url ?? "");
expect(output.successes).toEqual(["Relay enabled"]);
});
test("JSON mode never prompts and returns a structured relay-disabled error", async () => {
const confirmRelay = vi.fn(async () => true);
const output = createRecordedOutput();
await runPairCommand(
{ json: true },
{
resolveOffer: async () => disabledOffer,
confirmRelay,
printDirectGuidance: vi.fn(),
isInteractive: () => true,
output,
},
);
expect(confirmRelay).not.toHaveBeenCalled();
expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"');
expect(output.exitCode).toBe(1);
});
test("explicit relay opts in without prompting", async () => {
const resolveOffer = vi.fn(async () => enabledOffer);
const confirmRelay = vi.fn(async () => false);
const output = createRecordedOutput();
await runPairCommand(
{ relay: true, json: true },
{
resolveOffer,
confirmRelay,
printDirectGuidance: vi.fn(),
isInteractive: () => false,
output,
},
);
expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true }));
expect(confirmRelay).not.toHaveBeenCalled();
expect(output.exitCode).toBeUndefined();
});
test("surfaces launch-override rejection", async () => {
await expect(
runPairCommand(
{ relay: true },
{
resolveOffer: async () => {
throw new Error("Relay is controlled by a daemon launch override");
},
confirmRelay: vi.fn(),
printDirectGuidance: vi.fn(),
isInteractive: () => false,
output: createRecordedOutput(),
},
),
).rejects.toThrow("launch override");
});
});
+186 -51
View File
@@ -1,66 +1,97 @@
import { confirm, isCancel, log } from "@clack/prompts";
import { Command } from "commander";
import chalk from "chalk";
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server";
import {
generateLocalPairingOffer,
getOrCreateServerId,
loadConfig,
resolvePaseoHome,
} from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveLocalDaemonState } from "./local-daemon.js";
import { addJsonOption } from "../../utils/command-options.js";
import { formatPairingInstructions } from "../../output/pairing.js";
interface PairOptions {
home?: string;
json?: boolean;
relay?: boolean;
}
export interface PairCommandDependencies {
resolveOffer: typeof resolveLocalPairingOffer;
confirmRelay: typeof confirmRelayPairing;
printDirectGuidance: typeof printDirectConnectionGuidance;
isInteractive: () => boolean;
output: PairCommandOutput;
}
export interface PairCommandOutput {
columns: number | undefined;
writeStdout(message: string): void;
writeStderr(message: string): void;
setExitCode(code: number): void;
success(message: string): void;
}
export interface PairingOffer {
relayEnabled: boolean;
url: string | null;
qr: string | null;
}
const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500;
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
function createProcessOutput(): PairCommandOutput {
return {
columns: process.stdout.columns,
writeStdout(message) {
process.stdout.write(message);
},
writeStderr(message) {
process.stderr.write(message);
},
setExitCode(code) {
process.exitCode = code;
},
success(message) {
log.success(message);
},
};
}
export function pairCommand(): Command {
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--relay", "Enable relay without prompting")
.action(async (_options: PairOptions, command: Command) => {
await runPairCommand(command.optsWithGlobals());
});
}
export async function runPairCommand(options: PairOptions): Promise<void> {
if (options.home) {
process.env.PASEO_HOME = options.home;
export async function resolveLocalPairingOffer(options: {
paseoHome: string;
enableRelay?: boolean;
}): Promise<PairingOffer> {
const state = resolveLocalDaemonState({ home: options.paseoHome });
const serverId = getOrCreateServerId(state.home);
const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay);
if (daemonOffer) return daemonOffer;
if (state.running) {
throw new Error(
"The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.",
);
}
const paseoHome = resolvePaseoHome();
const state = resolveLocalDaemonState({ home: paseoHome });
const host = resolveTcpHostFromListen(state.listen);
// Try to get the pairing offer from the running daemon first.
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
const supportsDaemonStatusRpc =
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
if (supportsDaemonStatusRpc) {
try {
const offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
await client.close().catch(() => {});
outputPairingResult(
{ relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null },
options,
);
return;
} catch {
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
// v0.1.76; pairing should come from daemon.get_pairing_offer.
}
}
await client.close().catch(() => {});
}
const config = loadConfig(options.paseoHome);
if (options.enableRelay && !config.relayEnabled) {
throw new Error("Start the daemon before enabling relay for pairing.");
}
// Fall back to local pairing offer generation.
const config = loadConfig(paseoHome);
const pairing = await generateLocalPairingOffer({
paseoHome,
return generateLocalPairingOffer({
paseoHome: options.paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
@@ -69,28 +100,132 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
appBaseUrl: config.appBaseUrl,
includeQr: true,
});
}
outputPairingResult(pairing, options);
async function resolveDaemonPairingOffer(
listen: string,
expectedServerId: string,
enableRelay: boolean | undefined,
): Promise<PairingOffer | null> {
const client = await tryConnectToDaemon({
host: listen,
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
if (!client) return null;
try {
const serverInfo = client.getLastServerInfoMessage();
if (serverInfo?.serverId.trim() !== expectedServerId) {
throw new Error(
"The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.",
);
}
if (serverInfo?.features?.daemonStatusRpc !== true) {
throw new Error("Update the Paseo daemon before pairing from this command.");
}
let offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
if (!offer.relayEnabled && enableRelay) {
if (serverInfo.features.relayConfig !== true) {
throw new Error("Update the Paseo daemon before enabling relay from this command.");
}
await client.patchDaemonConfig({ relay: { enabled: true } });
offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
}
return {
relayEnabled: offer.relayEnabled,
url: offer.url || null,
qr: offer.qr ?? null,
};
} finally {
await client.close().catch(() => undefined);
}
}
export async function confirmRelayPairing(): Promise<boolean> {
log.message("Your connection is end-to-end encrypted. Paseo cannot read your code or messages.");
log.message(`Learn how it works: ${RELAY_DOCS_URL}`);
const answer = await confirm({
message: "Enable relay to pair a device?",
initialValue: false,
});
return !isCancel(answer) && answer;
}
export function printDirectConnectionGuidance(): void {
console.log("Daemon is running with relay off.");
console.log(
"To connect another device directly, use the daemon's TCP address over your LAN, Tailscale, or another VPN.",
);
console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`);
}
export async function runPairCommand(
options: PairOptions,
dependencyOverrides: Partial<PairCommandDependencies> = {},
): Promise<void> {
if (options.home) process.env.PASEO_HOME = options.home;
const dependencies: PairCommandDependencies = {
resolveOffer: resolveLocalPairingOffer,
confirmRelay: confirmRelayPairing,
printDirectGuidance: printDirectConnectionGuidance,
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
output: createProcessOutput(),
...dependencyOverrides,
};
const paseoHome = resolvePaseoHome();
let pairing = await dependencies.resolveOffer({
paseoHome,
enableRelay: options.relay === true,
});
const canPrompt = dependencies.isInteractive() && options.json !== true;
if (!pairing.relayEnabled && canPrompt) {
const shouldEnable = await dependencies.confirmRelay();
if (!shouldEnable) {
dependencies.printDirectGuidance();
dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`);
dependencies.output.setExitCode(1);
return;
}
pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true });
dependencies.output.success("Relay enabled");
}
outputPairingResult(pairing, options, dependencies.output);
}
function outputPairingResult(
pairing: { relayEnabled: boolean; url: string | null; qr: string | null },
pairing: PairingOffer,
options: PairOptions,
output: PairCommandOutput,
): void {
if (!pairing.relayEnabled || !pairing.url) {
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
console.error(chalk.yellow("Enable relay and run this command again."));
process.exit(1);
if (options.json) {
output.writeStderr(
`${JSON.stringify({
code: "RELAY_DISABLED",
message: "Relay pairing is disabled for this daemon.",
action: "Run paseo daemon pair --relay --json to enable it explicitly.",
})}\n`,
);
} else {
output.writeStderr(`${chalk.red("Relay pairing is disabled for this daemon.")}\n`);
output.writeStderr(`${chalk.yellow("Run paseo daemon pair --relay to enable it.")}\n`);
}
output.setExitCode(1);
return;
}
if (options.json) {
process.stdout.write(
output.writeStdout(
`${JSON.stringify(
{
relayEnabled: pairing.relayEnabled,
url: pairing.url,
qr: pairing.qr,
},
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr },
null,
2,
)}\n`,
@@ -98,11 +233,11 @@ function outputPairingResult(
return;
}
process.stdout.write(
output.writeStdout(
formatPairingInstructions({
url: pairing.url,
qr: pairing.qr,
columns: process.stdout.columns,
columns: output.columns,
}),
);
}
@@ -20,6 +20,7 @@ export function startCommand(): Command {
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--foreground", "Run in foreground (don't daemonize)")
.option("--relay", "Enable relay connection")
.option("--no-relay", "Disable relay connection")
.option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { selectRelayStatus } from "./status.js";
describe("selectRelayStatus", () => {
const persisted = {
enabled: false,
endpoint: "persisted.internal:443",
publicEndpoint: "persisted.example.com:443",
useTls: true,
publicUseTls: true,
};
test("uses the running daemon relay state over persisted config", () => {
expect(
selectRelayStatus({
persisted,
live: {
enabled: true,
endpoint: "live.internal:443",
publicEndpoint: "live.example.com:443",
useTls: true,
publicUseTls: true,
},
}),
).toBe("wss://live.example.com:443");
});
test("falls back to persisted config when the daemon cannot report live state", () => {
expect(selectRelayStatus({ persisted })).toBe("disabled");
});
});
+64 -21
View File
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveLocalDaemonState } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500;
@@ -204,6 +204,7 @@ interface DaemonProbeResult {
daemonVersion?: string | null;
daemonNodeOverride?: string;
daemonProviders?: ProviderBinaryStatus[];
relayStatus?: string;
note?: string;
}
@@ -262,6 +263,13 @@ async function probeDaemonOverWebsocket(args: {
version: p.available ? null : (p.error ?? null),
source: "daemon" as const,
}));
const relayStatus =
statusPayload.relay == null
? undefined
: selectRelayStatus({
persisted: relayConfigFromLocalState(state),
live: statusPayload.relay,
});
if (!state.running) {
return {
@@ -269,6 +277,7 @@ async function probeDaemonOverWebsocket(args: {
daemonVersion: statusPayload.version ?? daemonVersion,
daemonNodeOverride: statusPayload.nodePath,
daemonProviders,
relayStatus,
note: state.pidInfo
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
@@ -280,6 +289,7 @@ async function probeDaemonOverWebsocket(args: {
daemonVersion: statusPayload.version ?? daemonVersion,
daemonNodeOverride: statusPayload.nodePath,
daemonProviders,
relayStatus,
};
} catch {
return {
@@ -301,6 +311,7 @@ interface ProbeMergeState {
daemonNode: string;
daemonVersion: string | null;
daemonProviders: ProviderBinaryStatus[] | undefined;
relayStatus: string;
note: string | undefined;
}
@@ -312,6 +323,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "prob
daemonNode: probe.daemonNodeOverride ?? input.daemonNode,
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
relayStatus: probe.relayStatus ?? input.relayStatus,
note: probe.note ? appendNote(input.note, probe.note) : input.note,
};
}
@@ -336,10 +348,34 @@ async function resolveDaemonNodeLabel(
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
}
function formatRelayStatus(state: ReturnType<typeof resolveLocalDaemonState>): string {
if (!state.relayEnabled) return "disabled";
const scheme = state.relayPublicUseTls ? "wss" : "ws";
return `${scheme}://${state.relayEndpoint}`;
interface RelayStatusConfig {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
}
function relayConfigFromLocalState(
state: ReturnType<typeof resolveLocalDaemonState>,
): RelayStatusConfig {
return {
enabled: state.relayEnabled,
endpoint: state.relayEndpoint,
publicEndpoint: state.relayEndpoint,
useTls: state.relayUseTls,
publicUseTls: state.relayPublicUseTls,
};
}
export function selectRelayStatus(input: {
persisted: RelayStatusConfig;
live?: RelayStatusConfig;
}): string {
const relay = input.live ?? input.persisted;
if (!relay.enabled) return "disabled";
const scheme = relay.publicUseTls ? "wss" : "ws";
return `${scheme}://${relay.publicEndpoint}`;
}
export type StatusResult = ListResult<StatusRow>;
@@ -350,7 +386,7 @@ export async function runStatusCommand(
): Promise<StatusResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const daemonTarget = state.listen.trim();
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode = await resolveDaemonNodeLabel(state);
@@ -359,6 +395,7 @@ export async function runStatusCommand(
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let daemonVersion: string | null = null;
let daemonProviders: ProviderBinaryStatus[] | undefined;
let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) });
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
@@ -366,20 +403,26 @@ export async function runStatusCommand(
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
}
if (host) {
const probe = await probeDaemonOverWebsocket({ host, state });
({ connectedDaemon, localDaemon, daemonNode, daemonVersion, daemonProviders, note } =
applyProbeToStatus({
probe,
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
note,
}));
} else {
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
if (daemonTarget) {
const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state });
({
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
relayStatus,
note,
} = applyProbeToStatus({
probe,
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
relayStatus,
note,
}));
}
const cliVersion = resolveCliVersion();
@@ -398,7 +441,7 @@ export async function runStatusCommand(
connectedDaemon,
home: state.home,
listen: state.listen,
relay: formatRelayStatus(state),
relay: relayStatus,
hostname: state.pidInfo?.hostname ?? null,
pid: state.pidInfo?.pid ?? null,
startedAt: state.pidInfo?.startedAt ?? null,
+24 -53
View File
@@ -2,13 +2,7 @@ import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@cl
import { Command, Option } from "commander";
import { writeFileSync } from "node:fs";
import path from "node:path";
import {
generateLocalPairingOffer,
loadConfig,
loadPersistedConfig,
type CliConfigOverrides,
type PersistedConfig,
} from "@getpaseo/server";
import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server";
import {
resolveLocalPaseoHome,
resolveLocalDaemonState,
@@ -19,6 +13,11 @@ import {
} from "./daemon/local-daemon.js";
import { tryConnectToDaemon } from "../utils/client.js";
import { formatPairingInstructions } from "../output/pairing.js";
import {
confirmRelayPairing,
printDirectConnectionGuidance,
resolveLocalPairingOffer,
} from "./daemon/pair.js";
interface OnboardOptions extends DaemonStartOptions {
timeout?: string;
@@ -70,37 +69,6 @@ function parseTimeoutMs(raw: string | undefined): number {
return Math.ceil(seconds * 1000);
}
function toCliOverrides(options: OnboardOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {};
if (options.listen) {
cliOverrides.listen = options.listen;
} else if (options.port) {
cliOverrides.listen = `127.0.0.1:${options.port}`;
}
if (options.relay === false) {
cliOverrides.relayEnabled = false;
}
if (options.hostnames) {
const raw = options.hostnames.trim();
cliOverrides.hostnames =
raw.toLowerCase() === "true"
? true
: raw
.split(",")
.map((host) => host.trim())
.filter(Boolean);
}
if (options.mcp === false) {
cliOverrides.mcpEnabled = false;
}
return cliOverrides;
}
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
const configPath = path.join(paseoHome, "config.json");
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
@@ -338,6 +306,7 @@ export function onboardCommand(): Command {
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--relay", "Enable relay connection without prompting")
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option(
@@ -477,8 +446,6 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
}
const voiceEnabled = await resolveAndPersistVoice(paseoHome, options);
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) });
log.message(
voiceEnabled
? "Voice features enabled. Local speech models will be downloaded automatically if missing."
@@ -492,26 +459,30 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
richUi,
});
if (config.relayEnabled === false) {
log.warn("Relay is disabled; pairing offer is unavailable for this daemon.");
if (options.relay === false) {
log.message("Relay pairing skipped because --no-relay was provided.");
printNextSteps(null, paseoHome, richUi);
if (richUi) {
outro("Paseo daemon is running.");
}
if (richUi) outro("Paseo daemon is running.");
return;
}
const pairing = await generateLocalPairingOffer({
let pairing = await resolveLocalPairingOffer({
paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
relayPublicUseTls: config.relayPublicUseTls,
appBaseUrl: config.appBaseUrl,
includeQr: true,
enableRelay: options.relay === true,
});
if (!pairing.relayEnabled) {
const shouldEnable = richUi ? await confirmRelayPairing() : false;
if (!shouldEnable) {
printDirectConnectionGuidance();
printNextSteps(null, paseoHome, richUi);
if (richUi) outro("Paseo daemon is running.");
return;
}
pairing = await resolveLocalPairingOffer({ paseoHome, enableRelay: true });
log.success("Relay enabled");
}
if (!pairing.url) {
log.warn("Relay pairing URL is unavailable for this daemon configuration.");
printNextSteps(null, paseoHome, richUi);
+17
View File
@@ -15,6 +15,23 @@ describe("runCli", () => {
).toEqual(["node", "paseo", "onboard"]);
});
it("routes explicit root relay flags to onboard", () => {
expect(
createCliParseArgv({
argv: ["--relay"],
cwd: process.cwd(),
nodeArgv: ["node", "paseo"],
}),
).toEqual(["node", "paseo", "onboard", "--relay"]);
expect(
createCliParseArgv({
argv: ["--no-relay"],
cwd: process.cwd(),
nodeArgv: ["node", "paseo"],
}),
).toEqual(["node", "paseo", "onboard", "--no-relay"]);
});
it("preserves known CLI command argv", () => {
expect(
createCliParseArgv({
+7 -1
View File
@@ -25,7 +25,13 @@ export function createCliParseArgv(input: {
}
const nodeArgv = input.nodeArgv ?? ["paseo", "paseo"];
const cliArgv = invocation.argv.length === 0 ? ["onboard"] : invocation.argv;
const isOnboardRootFlag = invocation.argv[0] === "--relay" || invocation.argv[0] === "--no-relay";
let cliArgv = invocation.argv;
if (invocation.argv.length === 0) {
cliArgv = ["onboard"];
} else if (isOnboardRootFlag) {
cliArgv = ["onboard", ...invocation.argv];
}
return [...nodeArgv, ...cliArgv];
}
+4 -1
View File
@@ -200,7 +200,10 @@ function stripIpcPrefix(trimmed: string): string {
}
export function resolveDaemonTarget(host: string): DaemonTarget {
const trimmed = host.trim();
const trimmed = normalizeDaemonHost(host);
if (!trimmed) {
throw new Error(`Invalid daemon target: ${host}`);
}
if (
trimmed.startsWith("unix://") ||
trimmed.startsWith("pipe://") ||
+105 -16
View File
@@ -7,15 +7,16 @@
*
* Tests:
* - daemon --help shows subcommands
* - daemon pair prints a local pairing link without requiring a running daemon
* - daemon pair does not create a relay offer without explicit consent
* - daemon status reports stopped when daemon not running
* - daemon status --json outputs valid JSON
* - daemon stop handles daemon not running gracefully
* - daemon restart starts the daemon and can be cleaned up
* - daemon status probes the live relay state over local IPC
*/
import assert from "node:assert";
import { mkdtemp, rm } from "fs/promises";
import { mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { runLocalPaseo } from "./helpers/local-cli.ts";
@@ -44,14 +45,17 @@ try {
console.log("✓ daemon --help shows subcommands\n");
}
// Test 2: daemon pair works without daemon process
// Test 2: non-interactive pairing keeps relay disabled by default
{
console.log("Test 2: daemon pair prints local pairing URL");
console.log("Test 2: daemon pair requires explicit relay consent");
const result = await daemonCommand(["pair"]);
assert.strictEqual(result.exitCode, 0, "daemon pair should succeed");
assert(result.stdout.includes("Scan to pair:"), "output should include scan header");
assert(result.stdout.includes("#offer="), "output should include pairing offer fragment");
console.log("✓ daemon pair prints local pairing URL\n");
assert.strictEqual(result.exitCode, 1, "daemon pair should require relay consent");
assert(
result.stderr.includes("Relay pairing is disabled"),
"output should explain that relay pairing is disabled",
);
assert(!result.stdout.includes("#offer="), "output should not include a pairing offer");
console.log("✓ daemon pair requires explicit relay consent\n");
}
// Test 3: daemon status reports stopped when daemon not running
@@ -65,16 +69,17 @@ try {
console.log("✓ daemon status reports stopped when not running\n");
}
// Test 4: daemon pair --json outputs valid JSON
// Test 4: daemon pair --json exposes the machine-readable disabled state
{
console.log("Test 4: daemon pair --json outputs JSON");
console.log("Test 4: daemon pair --json reports relay disabled");
const result = await daemonCommand(["pair", "--json"]);
assert.strictEqual(result.exitCode, 0, "daemon pair --json should succeed");
const pairing = JSON.parse(result.stdout);
assert.strictEqual(pairing.relayEnabled, true, "pairing should report relay enabled");
assert.match(pairing.url, /#offer=/, "pairing URL should include offer fragment");
assert.strictEqual(typeof pairing.qr, "string", "pairing should include QR content");
console.log("✓ daemon pair --json outputs valid JSON\n");
assert.strictEqual(result.exitCode, 1, "daemon pair --json should require relay consent");
const errorLine = result.stderr.split("\n").find((line) => line.startsWith("{"));
assert(errorLine, "stderr should include a structured error");
const error = JSON.parse(errorLine);
assert.strictEqual(error.code, "RELAY_DISABLED", "error should identify relay state");
assert(!result.stdout.includes("#offer="), "output should not include a pairing offer");
console.log("✓ daemon pair --json reports relay disabled\n");
}
// Test 5: daemon status --json outputs valid JSON
@@ -119,6 +124,90 @@ try {
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart");
console.log("✓ daemon restart starts and stop cleanup succeeds\n");
}
// Test 8: status uses the running daemon as relay authority over local IPC
{
console.log("Test 8: daemon status probes live relay state over local IPC");
const listen =
process.platform === "win32"
? `\\\\.\\pipe\\paseo-status-${process.pid}-${Date.now()}`
: join(paseoHome, "status.sock");
const start = await daemonCommand(["start", "--listen", listen, "--relay"]);
assert.strictEqual(start.exitCode, 0, `IPC daemon should start: ${start.stderr}`);
const configPath = join(paseoHome, "config.json");
const config = JSON.parse(await readFile(configPath, "utf-8"));
config.daemon = { ...config.daemon, listen };
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
const pidPath = join(paseoHome, "paseo.pid");
const pidContents = await readFile(pidPath, "utf-8");
await unlink(pidPath);
const status = await daemonCommand(["status", "--json"]);
const pairing = await daemonCommand(["pair", "--json"]);
await writeFile(pidPath, pidContents, "utf-8");
assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`);
const payload = JSON.parse(status.stdout);
assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable");
assert.strictEqual(payload.localDaemon, "stopped", "missing PID should report stopped locally");
assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state");
assert.strictEqual(pairing.exitCode, 0, `IPC daemon pairing should succeed: ${pairing.stderr}`);
const pairingPayload = JSON.parse(pairing.stdout);
assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state");
assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer");
const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-"));
try {
await writeFile(
join(foreignHome, "config.json"),
`${JSON.stringify({ daemon: { listen } }, null, 2)}\n`,
"utf-8",
);
const foreignPairing = await runLocalPaseo(
["daemon", "pair", "--home", foreignHome, "--json"],
{ PASEO_HOME: foreignHome },
);
assert.notStrictEqual(
foreignPairing.exitCode,
0,
"pairing should reject a daemon owned by another home",
);
assert(
foreignPairing.stderr.includes("different Paseo home"),
"pairing should explain the daemon identity mismatch",
);
assert(!foreignPairing.stdout.includes("#offer="), "pairing should not expose another offer");
} finally {
await rm(foreignHome, { recursive: true, force: true });
}
const cleanup = await daemonCommand(["stop", "--force"]);
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after IPC status");
console.log("✓ daemon status probes live relay state over local IPC\n");
}
// Test 9: --relay accepts an already-enabled persisted relay while stopped
{
console.log("Test 9: daemon pair --relay accepts persisted relay while stopped");
const configPath = join(paseoHome, "config.json");
const config = JSON.parse(await readFile(configPath, "utf-8"));
config.daemon = {
...config.daemon,
relay: { ...config.daemon?.relay, enabled: true },
};
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
const pairing = await daemonCommand(["pair", "--relay", "--json"]);
assert.strictEqual(
pairing.exitCode,
0,
`persisted relay pairing should succeed while stopped: ${pairing.stderr}`,
);
const payload = JSON.parse(pairing.stdout);
assert.strictEqual(payload.relayEnabled, true, "pairing should preserve persisted relay state");
assert.match(payload.url, /#offer=/, "pairing should include the offline offer");
console.log("✓ daemon pair --relay accepts persisted relay while stopped\n");
}
} finally {
// Best-effort daemon cleanup in case assertions fail before explicit stop.
await daemonCommand(["stop", "--force"]);
+26 -7
View File
@@ -15,7 +15,7 @@ const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-"));
const port = await getAvailablePort();
try {
console.log("Test 1: `paseo` runs blocking onboarding and prints pairing info");
console.log("Test 1: `paseo` runs blocking onboarding without implicit relay pairing");
const onboard =
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow();
@@ -24,12 +24,12 @@ try {
0,
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`,
);
assert(onboard.stdout.includes("Scan to pair"), "onboard output should include scan header");
assert(!onboard.stdout.includes("Scan to pair"), "onboard output should not include scan header");
assert(!onboard.stdout.includes("#offer="), "onboard output should not include a pairing offer");
assert(
onboard.stdout.includes("Pairing link"),
"onboard output should include pairing link header",
onboard.stdout.includes("Daemon is running with relay off"),
"onboard output should explain the direct connection path",
);
assert(onboard.stdout.includes("#offer="), "onboard output should include pairing offer URL");
assert(
onboard.stdout.includes("CLI quick reference"),
"onboard output should include CLI quick reference",
@@ -50,9 +50,28 @@ try {
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow();
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`);
assert(status.stdout.includes("running"), "daemon should be running when onboarding exits");
console.log("✓ onboarding prints pairing info and waits for daemon readiness\n");
console.log("✓ onboarding keeps relay disabled and waits for daemon readiness\n");
console.log("Test 2: non-interactive onboarding persists voice disabled config");
console.log("Test 2: --no-relay suppresses pairing for an already-running daemon");
const enableRelay =
await $`PASEO_HOME=${paseoHome} npx paseo daemon pair --home ${paseoHome} --relay`.nothrow();
assert.strictEqual(enableRelay.exitCode, 0, `relay enable should succeed: ${enableRelay.stderr}`);
assert(enableRelay.stdout.includes("#offer="), "relay enable should produce a pairing offer");
const noRelayOnboard =
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} npx paseo --no-relay`.nothrow();
assert.strictEqual(
noRelayOnboard.exitCode,
0,
`--no-relay onboarding should succeed: ${noRelayOnboard.stderr}`,
);
assert(
!noRelayOnboard.stdout.includes("#offer="),
"--no-relay onboarding should not include a pairing offer",
);
console.log("✓ --no-relay suppresses pairing for an already-running daemon\n");
console.log("Test 3: non-interactive onboarding persists voice disabled config");
const configRaw = await readFile(join(paseoHome, "config.json"), "utf-8");
const config = JSON.parse(configRaw) as {
features?: {
@@ -26,6 +26,17 @@ console.log("=== CLI IPC Target Helpers ===\n");
console.log("✓ unix hosts resolve to ws+unix URLs\n");
}
{
console.log("Test 1b: bare unix socket paths resolve at the connection boundary");
const target = resolveDaemonTarget("/tmp/paseo.sock");
assert.deepStrictEqual(target, {
type: "ipc",
url: "ws+unix:///tmp/paseo.sock:/ws",
socketPath: "/tmp/paseo.sock",
});
console.log("✓ bare unix socket paths resolve at the connection boundary\n");
}
{
console.log("Test 2: pipe hosts preserve the Node socketPath transport form");
const target = resolveDaemonTarget("pipe://\\\\.\\pipe\\paseo-managed-test");
@@ -78,12 +78,6 @@ interface DesktopDaemonLogs {
contents: string;
}
interface DesktopPairingOffer {
relayEnabled: boolean;
url: string | null;
qr: string | null;
}
function parseReleaseChannel(
args: Record<string, unknown> | undefined,
): AppReleaseChannel | undefined {
@@ -219,18 +213,6 @@ function logDesktopDaemonLifecycle(message: string, details?: Record<string, unk
});
}
function toTrimmedString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function resolveDesktopAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
@@ -509,36 +491,6 @@ async function getCliDaemonStatus(): Promise<string> {
return await runExternalCliTextCommand(["daemon", "status"]);
}
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
const status = await resolveDesktopDaemonStatus();
if (status.status !== "running") {
return {
relayEnabled: false,
url: null,
qr: null,
};
}
try {
const payload = await runExternalCliJsonCommand(["daemon", "pair", "--json"]);
if (!isRecord(payload)) {
throw new Error("Daemon pairing response was not an object.");
}
return {
relayEnabled: payload.relayEnabled === true,
url: toTrimmedString(payload.url),
qr: toTrimmedString(payload.qr),
};
} catch {
return {
relayEnabled: false,
url: null,
qr: null,
};
}
}
async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> {
const status = await resolveDesktopDaemonStatus();
if (status.status !== "running") {
@@ -573,7 +525,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
restart_desktop_daemon: () => restartDaemon(),
desktop_daemon_logs: () => getDaemonLogs(),
desktop_app_logs: () => getDesktopAppLogs(),
desktop_daemon_pairing: () => getDaemonPairing(),
desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000,
cli_daemon_status: () => getCliDaemonStatus(),
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
+10
View File
@@ -139,8 +139,15 @@ const MutableBrowserToolsConfigSchema = z
enabled: z.boolean().default(false),
})
.passthrough();
const MutableRelayConfigSchema = z
.object({
enabled: z.boolean(),
})
.passthrough();
export const MutableDaemonConfigSchema = z
.object({
// COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported.
relay: MutableRelayConfigSchema.optional(),
mcp: z
.object({
injectIntoAgents: z.boolean(),
@@ -158,6 +165,7 @@ export const MutableDaemonConfigSchema = z
export const MutableDaemonConfigPatchSchema = z
.object({
relay: MutableRelayConfigSchema.partial().optional(),
mcp: MutableDaemonConfigSchema.shape.mcp.partial().optional(),
browserTools: MutableBrowserToolsConfigSchema.partial().optional(),
providers: z
@@ -2808,6 +2816,8 @@ export const ServerInfoStatusPayloadSchema = z
forgeSearch: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
relayConfig: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
@@ -1,7 +1,7 @@
import os from "node:os";
import http from "node:http";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
@@ -12,8 +12,18 @@ import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
import type {
HubEnrollment,
HubEnrollmentResult,
HubRelationshipRemote,
HubRevocation,
HubSocketConnection,
HubSocketCredentials,
HubSocketEvents,
} from "./hub/relationship-remote.js";
interface HeldAgentClose {
started: Promise<void>;
@@ -224,6 +234,103 @@ describe("paseo daemon bootstrap", () => {
}
});
test("relay config changes during Hub enrollment reach the live runtime", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-startup-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
await writeFile(
path.join(paseoHome, "hub-relationship.json"),
`${JSON.stringify({
version: 1,
state: "pending",
relationship: {
daemonId: "daemon-startup-race",
idempotencyKey: "enrollment-startup-race",
hubOrigin: "https://hub.test",
createdAt: "2026-07-31T00:00:00.000Z",
scopes: ["hub.execution.*"],
},
credential: { secret: "credential" },
enrollment: { token: "enrollment-token" },
identity: { serverId: "server-startup-race", daemonPublicKey: "public-key" },
})}\n`,
"utf-8",
);
let markEnrollmentStarted: () => void = () => undefined;
const enrollmentStarted = new Promise<void>((resolve) => {
markEnrollmentStarted = resolve;
});
let releaseEnrollment: () => void = () => undefined;
const enrollmentReleased = new Promise<void>((resolve) => {
releaseEnrollment = resolve;
});
const remote: HubRelationshipRemote = {
async enroll(input: HubEnrollment): Promise<HubEnrollmentResult> {
markEnrollmentStarted();
await enrollmentReleased;
return {
daemonId: input.daemonId,
scopes: input.scopes,
webSocketUrl: "wss://hub.test/daemon",
};
},
async revoke(_input: HubRevocation): Promise<void> {},
openSocket(_input: HubSocketCredentials, _events: HubSocketEvents): HubSocketConnection {
return { close: () => undefined };
},
};
const config: PaseoDaemonConfig = {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
relayEndpoint: "127.0.0.1:9",
relayUseTls: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }), {
hubRelationshipRemote: remote,
});
const starting = daemon.start();
let client: DaemonClient | null = null;
try {
await enrollmentStarted;
const listenTarget = daemon.getListenTarget();
if (!listenTarget || listenTarget.type !== "tcp") {
throw new Error("Expected daemon TCP listener during Hub enrollment");
}
client = new DaemonClient({
url: `ws://127.0.0.1:${listenTarget.port}/ws`,
appVersion: "0.1.82",
});
await client.connect();
await client.patchDaemonConfig({ relay: { enabled: true } });
releaseEnrollment();
await starting;
const status = await client.getDaemonStatus();
expect(status.relay?.enabled).toBe(true);
} finally {
releaseEnrollment();
await starting.catch(() => undefined);
await client?.close().catch(() => undefined);
await daemon.stop().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
test("stops new connections and agent registrations before closing agents", async () => {
const shutdown = await beginDaemonShutdownWithAgentClosing();
try {
+43 -42
View File
@@ -159,9 +159,8 @@ import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js";
import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
@@ -395,6 +394,7 @@ export interface PaseoDaemonConfig {
agentClients: Partial<Record<AgentProvider, AgentClient>>;
agentStoragePath: string;
relayEnabled?: boolean;
relayEnabledMutable?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
relayUseTls?: boolean;
@@ -449,6 +449,10 @@ export interface PaseoDaemonDependencies {
hubRelationshipClock?: HubRelationshipClock;
hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy;
createHubDaemonId?: () => string;
serverFeatureOverrides?: {
daemonStatusRpc?: boolean;
relayConfig?: boolean;
};
}
function createBootstrapManagedProcessRegistry(
@@ -507,6 +511,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
);
const initialConfig: MutableDaemonConfig = {
relay: { enabled: config.relayEnabled ?? true },
mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true },
browserTools: { enabled: config.browserToolsEnabled ?? false },
providers,
@@ -538,6 +543,7 @@ export async function createPaseoDaemon(
config.paseoHome,
createInitialMutableDaemonConfig(config),
logger,
{ relayEnabledMutable: config.relayEnabledMutable ?? true },
);
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({});
@@ -551,7 +557,7 @@ export async function createPaseoDaemon(
void reconcileManagedProcessLedger(managedProcesses, logger).catch((error) => {
logger.warn({ err: error }, "Failed to reconcile managed helper process ledger");
});
let relayTransport: RelayTransportController | null = null;
let relayRuntime: RelayRuntime | null = null;
const staticDir = config.staticDir;
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
@@ -1453,8 +1459,6 @@ export async function createPaseoDaemon(
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (boundListenTarget.type === "tcp") {
logger.info(
{
@@ -1489,7 +1493,12 @@ export async function createPaseoDaemon(
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
{
allowedOrigins,
hostnames: configuredHostnames,
daemonStatusRpc: dependencies.serverFeatureOverrides?.daemonStatusRpc,
relayConfig: dependencies.serverFeatureOverrides?.relayConfig,
},
workspaceAutoName,
config.auth,
speechService,
@@ -1526,47 +1535,39 @@ export async function createPaseoDaemon(
worktreesRoot: config.worktreesRoot,
appBaseUrl: config.appBaseUrl,
desktopManaged: config.desktopManaged === true,
relay: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
getRelayConfig: () =>
relayRuntime?.getConfig() ?? {
enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
},
serviceProxyPublicBaseUrl,
browserToolsBroker,
hubRelationships,
);
relayRuntime = createRelayRuntime({
config: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
logger,
attachSocket: async (ws, metadata) => {
if (!wsServer) throw new Error("WebSocket server is not ready");
await wsServer.attachExternalSocket(ws, metadata);
},
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
daemonConfigStore.onFieldChange("relay.enabled", (value) => {
relayRuntime?.setEnabled(value === true);
});
await hubRelationships.start();
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: {
endpoint: relayPublicEndpoint,
useTls: relayPublicUseTls,
},
});
encodeOfferToFragmentUrl({ offer, appBaseUrl });
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
relayUseTls,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
};
logAndResolve().then(resolve, reject);
@@ -1613,7 +1614,7 @@ export async function createPaseoDaemon(
terminalManager.killAll();
speechService.stop();
await scheduleService.stop().catch(() => undefined);
await relayTransport?.stop().catch(() => undefined);
await relayRuntime?.stop().catch(() => undefined);
if (wsServer) {
await wsServer.close();
}
@@ -21,6 +21,44 @@ describe("daemon relay config", () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("preserves implicit relay-on for a legacy config without enabled", async () => {
const home = await createPaseoHome({ version: 1, daemon: { relay: {} } });
expect(loadConfig(home, { env: {} }).relayEnabled).toBe(true);
});
test("keeps explicit persisted relay state and marks it mutable", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { relay: { enabled: false } },
});
const config = loadConfig(home, { env: {} });
expect(config.relayEnabled).toBe(false);
expect(config.relayEnabledMutable).toBe(true);
});
test("marks environment relay overrides immutable", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { relay: { enabled: false } },
});
const config = loadConfig(home, { env: { PASEO_RELAY_ENABLED: "true" } });
expect(config.relayEnabled).toBe(true);
expect(config.relayEnabledMutable).toBe(false);
});
test.each(["", "treu"])(
"ignores invalid relay override %j without locking config",
async (value) => {
const home = await createPaseoHome({
version: 1,
daemon: { relay: { enabled: false } },
});
const config = loadConfig(home, { env: { PASEO_RELAY_ENABLED: value } });
expect(config.relayEnabled).toBe(false);
expect(config.relayEnabledMutable).toBe(true);
},
);
test("loads relay TLS from env, persisted config, and hosted relay fallback", async () => {
const persistedHome = await createPaseoHome({
version: 1,
+14 -5
View File
@@ -187,6 +187,7 @@ interface ResolveRelayInput {
interface ResolvedRelay {
enabled: boolean;
enabledMutable: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
@@ -210,11 +211,11 @@ function resolveTlsFromEnv(
}
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED);
// COMPAT(relayOptInDefault): configs created before v0.2.6 may omit this field.
// Preserve their relay-on behavior until 2027-01-31; new homes materialize false.
const enabled =
input.cliRelayEnabled ??
parseBooleanEnv(input.env.PASEO_RELAY_ENABLED) ??
input.persisted.daemon?.relay?.enabled ??
true;
input.cliRelayEnabled ?? environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? true;
const endpoint =
input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ??
@@ -235,7 +236,14 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
input.persisted.daemon?.relay?.publicUseTls,
useTls,
);
return { enabled, endpoint, publicEndpoint, useTls, publicUseTls };
return {
enabled,
enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined,
endpoint,
publicEndpoint,
useTls,
publicUseTls,
};
}
interface ResolvedVoiceLlm {
@@ -506,6 +514,7 @@ export function loadConfig(
staticDir: "public",
agentClients: {},
relayEnabled: relay.enabled,
relayEnabledMutable: relay.enabledMutable,
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
@@ -63,6 +63,110 @@ describe("DaemonConfigStore", () => {
}
});
test("patch persists relay state and emits its field change", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
const changes: unknown[] = [];
store.onFieldChange("relay.enabled", (value) => changes.push(value));
store.patch({ relay: { enabled: true } });
expect(changes).toEqual([true]);
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("rolls back config when a field transition fails", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
store.onFieldChange("relay.enabled", (enabled) => {
if (enabled === true) {
throw new Error("Relay transport failed to start");
}
});
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
"Relay transport failed to start",
);
expect(store.get().relay?.enabled).toBe(false);
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("rejects relay patches when a launch override owns the setting", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
{ relayEnabledMutable: false },
);
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
"Relay is controlled by a daemon launch override",
);
});
test("unrelated patches do not persist a one-launch relay override", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const persisted = loadPersistedConfig(paseoHome);
writeFileSync(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
...persisted,
daemon: { ...persisted.daemon, relay: { enabled: false } },
})}\n`,
);
const store = new DaemonConfigStore(
paseoHome,
{
relay: { enabled: true },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
{ relayEnabledMutable: false },
);
store.patch({ browserTools: { enabled: true } });
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("patch persists provider enabled flags into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -27,6 +27,11 @@ export interface DaemonConfigChangeDetails {
type ConfigListener = (config: MutableDaemonConfig, details: DaemonConfigChangeDetails) => void;
type FieldChangeHandler = (value: unknown) => void;
interface AppliedFieldChange {
handler: FieldChangeHandler;
previousValue: unknown;
}
function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
return logger?.child({ module: "daemon-config-store" });
}
@@ -162,11 +167,21 @@ export class DaemonConfigStore {
private readonly logger: LoggerLike | undefined;
private readonly changeListeners = new Set<ConfigListener>();
private readonly fieldChangeHandlers = new Map<string, Set<FieldChangeHandler>>();
private readonly relayEnabledMutable: boolean;
constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) {
constructor(
paseoHome: string,
initial: MutableDaemonConfig,
logger?: LoggerLike,
options: { relayEnabledMutable?: boolean } = {},
) {
this.paseoHome = paseoHome;
this.logger = getLogger(logger);
this.current = MutableDaemonConfigSchema.parse(initial);
this.current = MutableDaemonConfigSchema.parse({
...initial,
relay: initial.relay ?? { enabled: true },
});
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
}
public get(): MutableDaemonConfig {
@@ -175,6 +190,11 @@ export class DaemonConfigStore {
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
throw new Error(
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
);
}
const { removeProviders = [], ...configPatch } = parsedPatch;
const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch);
@@ -194,24 +214,32 @@ export class DaemonConfigStore {
return this.current;
}
// Persist before updating in-memory state so that if persistence fails,
// runtime and disk stay consistent.
this.persistConfig(next, removedProviders);
if (!configChanged) {
return this.current;
}
const persistedBeforePatch = this.persistConfig(next, removedProviders);
if (!configChanged) return this.current;
const previous = this.current;
const appliedFieldChanges: AppliedFieldChange[] = [];
this.current = next;
for (const path of changedFieldPaths) {
const handlers = this.fieldChangeHandlers.get(path);
if (!handlers) {
continue;
try {
for (const path of changedFieldPaths) {
const handlers = this.fieldChangeHandlers.get(path);
if (!handlers) {
continue;
}
const value = getValueAtPath(next, path);
const previousValue = getValueAtPath(previous, path);
for (const handler of handlers) {
appliedFieldChanges.push({ handler, previousValue });
handler(value);
}
}
const value = getValueAtPath(next, path);
for (const handler of handlers) {
handler(value);
} catch (error) {
this.current = previous;
for (const change of appliedFieldChanges.toReversed()) {
change.handler(change.previousValue);
}
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
throw error;
}
const changeDetails: DaemonConfigChangeDetails = { removedProviders };
@@ -246,14 +274,19 @@ export class DaemonConfigStore {
};
}
private persistConfig(config: MutableDaemonConfig, removeProviders: readonly string[]): void {
private persistConfig(
config: MutableDaemonConfig,
removeProviders: readonly string[],
): PersistedConfig {
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const nextPersisted = mergeMutableConfigIntoPersistedConfig({
persisted,
mutable: config,
removeProviders,
persistRelayEnabled: this.relayEnabledMutable,
});
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
return persisted;
}
}
@@ -261,8 +294,12 @@ function mergeMutableConfigIntoPersistedConfig(params: {
persisted: PersistedConfig;
mutable: MutableDaemonConfig;
removeProviders: readonly string[];
persistRelayEnabled: boolean;
}): PersistedConfig {
const { persisted, mutable, removeProviders } = params;
const { persisted, mutable, removeProviders, persistRelayEnabled } = params;
if (!mutable.relay) {
throw new Error("Mutable daemon config is missing relay state");
}
const browserToolsEnabled = readBrowserToolsEnabled(mutable);
const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
const persistedProviderOverrides = omitProvidersFromOverrides(
@@ -300,6 +337,14 @@ function mergeMutableConfigIntoPersistedConfig(params: {
...persisted,
daemon: {
...persisted.daemon,
...(persistRelayEnabled
? {
relay: {
...persisted.daemon?.relay,
enabled: mutable.relay.enabled,
},
}
: {}),
mcp: {
...persisted.daemon?.mcp,
injectIntoAgents: mutable.mcp.injectIntoAgents,
@@ -84,8 +84,13 @@ function bootstrapFromEnvironment(): BootstrapResult {
}
function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
if (process.argv.includes("--relay")) {
config.relayEnabled = true;
config.relayEnabledMutable = false;
}
if (process.argv.includes("--no-relay")) {
config.relayEnabled = false;
config.relayEnabledMutable = false;
}
if (process.argv.includes("--relay-use-tls")) {
config.relayUseTls = true;
@@ -648,6 +648,16 @@ describe("PersistedConfigSchema voice mode config", () => {
});
describe("loadPersistedConfig", () => {
test("materializes relay disabled for a new Paseo home", () => {
const home = createTempHome();
try {
const config = loadPersistedConfig(home);
expect(config.daemon?.relay?.enabled).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("accepts the documented config schema marker", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
@@ -334,7 +334,7 @@ const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({
allowedOrigins: ["https://app.paseo.sh"],
},
relay: {
enabled: true,
enabled: false,
},
},
app: {
@@ -0,0 +1,64 @@
import { describe, expect, test, vi } from "vitest";
import pino from "pino";
import { generateKeyPair } from "@getpaseo/relay";
import { createRelayRuntime } from "./relay-runtime.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
describe("RelayRuntime", () => {
test("starts and stops transport as enabled state changes", async () => {
const stops: Array<ReturnType<typeof vi.fn>> = [];
const starts: string[] = [];
const startTransport: typeof startRelayTransport = (options) => {
starts.push(options.relayEndpoint);
const stop = vi.fn(async () => undefined);
stops.push(stop);
return { stop } satisfies RelayTransportController;
};
const runtime = createRelayRuntime({
config: {
enabled: false,
endpoint: "relay.example.test:443",
publicEndpoint: "relay.example.test:443",
useTls: true,
publicUseTls: true,
},
logger: pino({ level: "silent" }),
attachSocket: async () => undefined,
serverId: "relay-runtime-test",
daemonKeyPair: generateKeyPair(),
startTransport,
});
expect(starts).toEqual([]);
runtime.setEnabled(true);
runtime.setEnabled(true);
expect(starts).toEqual(["relay.example.test:443"]);
expect(runtime.getConfig().enabled).toBe(true);
runtime.setEnabled(false);
await vi.waitFor(() => expect(stops[0]).toHaveBeenCalledOnce());
expect(runtime.getConfig().enabled).toBe(false);
});
test("keeps relay disabled when transport startup fails", () => {
const runtime = createRelayRuntime({
config: {
enabled: false,
endpoint: "invalid-endpoint",
publicEndpoint: "invalid-endpoint",
useTls: false,
publicUseTls: false,
},
logger: pino({ level: "silent" }),
attachSocket: async () => undefined,
serverId: "relay-runtime-test",
daemonKeyPair: generateKeyPair(),
startTransport: () => {
throw new Error("Invalid relay endpoint");
},
});
expect(() => runtime.setEnabled(true)).toThrow("Invalid relay endpoint");
expect(runtime.getConfig().enabled).toBe(false);
});
});
@@ -0,0 +1,78 @@
import type pino from "pino";
import type { KeyPair } from "@getpaseo/relay/e2ee";
import type { ExternalSocketMetadata } from "./websocket-server.js";
import {
startRelayTransport,
type RelaySocketLike,
type RelayTransportController,
} from "./relay-transport.js";
export interface RelayRuntimeConfig {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
}
interface RelayRuntimeOptions {
config: RelayRuntimeConfig;
logger: pino.Logger;
attachSocket(ws: RelaySocketLike, metadata?: ExternalSocketMetadata): Promise<void>;
serverId: string;
daemonKeyPair: KeyPair;
startTransport?: typeof startRelayTransport;
}
export interface RelayRuntime {
getConfig(): RelayRuntimeConfig;
setEnabled(enabled: boolean): void;
stop(): Promise<void>;
}
export function createRelayRuntime(options: RelayRuntimeOptions): RelayRuntime {
const startTransport = options.startTransport ?? startRelayTransport;
let config = options.config;
let transport: RelayTransportController | null = null;
function start(): void {
if (transport) return;
transport = startTransport({
logger: options.logger,
attachSocket: options.attachSocket,
relayEndpoint: config.endpoint,
relayUseTls: config.useTls,
serverId: options.serverId,
daemonKeyPair: options.daemonKeyPair,
});
}
function setEnabled(enabled: boolean): void {
if (config.enabled === enabled) return;
if (enabled) {
start();
config = { ...config, enabled: true };
return;
}
config = { ...config, enabled: false };
const current = transport;
transport = null;
void current?.stop().catch((error) => {
options.logger.warn({ err: error }, "Failed to stop relay transport");
});
}
async function stop(): Promise<void> {
const current = transport;
transport = null;
await current?.stop();
}
if (config.enabled) start();
return {
getConfig: () => config,
setEnabled,
stop,
};
}
@@ -11,7 +11,7 @@ import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import type { ExternalSocketMetadata } from "./websocket-server.js";
import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js";
interface RelayTransportOptions {
export interface RelayTransportOptions {
logger: pino.Logger;
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>;
relayEndpoint: string; // "host:port"
@@ -25,7 +25,7 @@ export interface RelayTransportController {
stop: () => Promise<void>;
}
interface RelaySocketLike {
export interface RelaySocketLike {
readyState: number;
bufferedAmount?: number;
send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void;
+4 -4
View File
@@ -1425,7 +1425,7 @@ describe("daemon status + pairing RPC", () => {
paseoHome: makeHome(),
serverId: "srv-test",
daemonVersion: "9.9.9",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
agentManager: {
listProviderAvailability: vi.fn().mockResolvedValue([
{ provider: "claude", available: true },
@@ -1464,7 +1464,7 @@ describe("daemon status + pairing RPC", () => {
paseoHome: makeHome(),
serverId: "srv-test",
daemonVersion: "9.9.9",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
agentManager: {
listProviderAvailability: vi.fn().mockRejectedValue(new Error("provider listing failed")),
},
@@ -1500,13 +1500,13 @@ describe("daemon status + pairing RPC", () => {
paseoHome: makeHome(),
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
relay: {
getRelayConfig: () => ({
enabled: false,
endpoint: "relay.paseo.sh:443",
publicEndpoint: "relay.paseo.sh:443",
useTls: true,
publicUseTls: true,
},
}),
},
});
@@ -127,7 +127,7 @@ describe("DaemonSession", () => {
const { subsystem, emitted } = makeSubsystem({
serverId: "srv-1",
daemonVersion: "1.2.3",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
listProviderAvailability: async () => [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "boom" },
@@ -161,7 +161,7 @@ describe("DaemonSession", () => {
const { subsystem, emitted } = makeSubsystem({
serverId: "srv-1",
daemonVersion: "1.2.3",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
listProviderAvailability: async () => {
throw new Error("provider listing failed");
},
@@ -191,13 +191,13 @@ describe("DaemonSession", () => {
const { subsystem, emitted } = makeSubsystem({
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
relay: {
getRelayConfig: () => ({
enabled: false,
endpoint: "relay.paseo.sh:443",
publicEndpoint: "relay.paseo.sh:443",
useTls: true,
publicUseTls: true,
},
}),
},
});
@@ -219,13 +219,13 @@ describe("DaemonSession", () => {
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
appBaseUrl: "https://app.example.test",
relay: {
getRelayConfig: () => ({
enabled: true,
endpoint: "relay.example.test:443",
publicEndpoint: "relay.example.test:443",
useTls: true,
publicUseTls: true,
},
}),
},
});
@@ -246,19 +246,53 @@ describe("DaemonSession", () => {
expect(typeof message.payload.qr).toBe("string");
});
test("pairing offer reads relay state at request time", async () => {
let enabled = false;
const { subsystem, emitted } = makeSubsystem({
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
appBaseUrl: "https://app.example.test",
getRelayConfig: () => ({
enabled,
endpoint: "relay.example.test:443",
publicEndpoint: "relay.example.test:443",
useTls: true,
publicUseTls: true,
}),
},
});
await subsystem.handleGetPairingOfferRequest({
type: "daemon.get_pairing_offer.request",
requestId: "disabled",
});
enabled = true;
await subsystem.handleGetPairingOfferRequest({
type: "daemon.get_pairing_offer.request",
requestId: "enabled",
});
const pairingResponses = emitted.filter(
(message) => message.type === "daemon.get_pairing_offer.response",
);
expect(pairingResponses[0]?.payload.relayEnabled).toBe(false);
expect(pairingResponses[1]?.payload.relayEnabled).toBe(true);
expect(pairingResponses[1]?.payload.url).toContain("#offer=");
});
test("diagnostics includes a log tail and redacts connection secrets", async () => {
const { subsystem, emitted, paseoHome } = makeSubsystem({
serverId: "srv-1",
daemonVersion: "1.2.3",
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
relay: {
getRelayConfig: () => ({
enabled: true,
endpoint: "relay.secret.test:443",
publicEndpoint: "relay.secret.test:443",
useTls: true,
publicUseTls: true,
},
}),
},
});
writeFileSync(
@@ -17,7 +17,7 @@ export interface DaemonRuntimeConfig {
worktreesRoot?: string;
appBaseUrl?: string;
desktopManaged?: boolean;
relay: {
getRelayConfig(): {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
@@ -169,7 +169,7 @@ export class DaemonSession {
nodePath: process.execPath,
startedAt: pidInfo?.startedAt ?? null,
listen: this.daemonRuntimeConfig?.listen ?? null,
relay: this.daemonRuntimeConfig?.relay ?? null,
relay: this.daemonRuntimeConfig?.getRelayConfig() ?? null,
providers,
},
});
@@ -196,10 +196,10 @@ export class DaemonSession {
msg: Extract<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
): Promise<void> {
try {
const relay = this.daemonRuntimeConfig?.relay;
const relay = this.daemonRuntimeConfig?.getRelayConfig();
const pairing = await generateLocalPairingOffer({
paseoHome: this.paseoHome,
relayEnabled: relay?.enabled ?? true,
relayEnabled: relay?.enabled ?? false,
relayEndpoint: relay?.endpoint,
relayPublicEndpoint: relay?.publicEndpoint,
relayUseTls: relay?.useTls,
@@ -133,7 +133,7 @@ function collectProcessEntries(options: DaemonDiagnosticsOptions): DiagnosticEnt
}
function collectRuntimeConfigEntries(options: DaemonDiagnosticsOptions): DiagnosticEntry[] {
const relay = options.daemonRuntimeConfig?.relay ?? null;
const relay = options.daemonRuntimeConfig?.getRelayConfig() ?? null;
return [
{ label: "Listen", value: formatListenKind(options.daemonRuntimeConfig?.listen ?? null) },
{ label: "Relay enabled", value: relay ? String(relay.enabled) : "false" },
@@ -536,8 +536,8 @@ export function redactDiagnostic(
let redacted = value;
const sensitiveValues = [
options?.daemonRuntimeConfig?.listen,
options?.daemonRuntimeConfig?.relay?.endpoint,
options?.daemonRuntimeConfig?.relay?.publicEndpoint,
options?.daemonRuntimeConfig?.getRelayConfig()?.endpoint,
options?.daemonRuntimeConfig?.getRelayConfig()?.publicEndpoint,
].filter((item): item is string => Boolean(item));
for (const sensitive of sensitiveValues) {
@@ -12,6 +12,8 @@ async function main(): Promise<void> {
corsAllowedOrigins: [`http://localhost:${metroPort}`],
daemonVersion: "0.0.0",
desktopManaged: process.env.E2E_DESKTOP_MANAGED === "1",
daemonStatusRpcCapability: process.env.E2E_DAEMON_STATUS_RPC_CAPABILITY !== "0",
relayConfigCapability: process.env.E2E_RELAY_CONFIG_CAPABILITY !== "0",
});
const serverId = (await readFile(path.join(daemon.paseoHome, "server-id"), "utf8")).trim();
@@ -27,6 +27,8 @@ interface TestPaseoDaemonOptions {
relayEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
daemonStatusRpcCapability?: boolean;
relayConfigCapability?: boolean;
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
providerOverrides?: PaseoDaemonConfig["providerOverrides"];
paseoHomeRoot?: string;
@@ -92,7 +94,12 @@ export async function createTestPaseoDaemon(
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options);
const logger = options.logger ?? pino({ level: "silent" });
const daemon = await createPaseoDaemon(config, logger);
const daemon = await createPaseoDaemon(config, logger, {
serverFeatureOverrides: {
daemonStatusRpc: options.daemonStatusRpcCapability,
relayConfig: options.relayConfigCapability,
},
});
try {
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
const listenTarget = daemon.getListenTarget();
@@ -131,6 +131,8 @@ interface WebSocketConnectionIdentity {
interface WebSocketServerConfig {
allowedOrigins: Set<string>;
hostnames?: HostnamesConfig;
daemonStatusRpc?: boolean;
relayConfig?: boolean;
}
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
@@ -554,6 +556,8 @@ export class VoiceAssistantWebSocketServer {
private readonly hubRelationships: HubRelationshipManagement | null;
private readonly browserToolsRegistrations = new Map<string, BrowserToolsRegistration>();
private acceptingConnections = true;
private readonly advertiseDaemonStatusRpc: boolean;
private readonly advertiseRelayConfig: boolean;
constructor(
server: HTTPServer,
@@ -601,6 +605,8 @@ export class VoiceAssistantWebSocketServer {
hubRelationships?: HubRelationshipManagement | null,
) {
this.logger = logger.child({ module: "websocket-server" });
this.advertiseDaemonStatusRpc = wsConfig.daemonStatusRpc !== false;
this.advertiseRelayConfig = wsConfig.relayConfig !== false;
this.serverId = serverId;
if (typeof daemonVersion !== "string" || daemonVersion.trim().length === 0) {
throw new MissingDaemonVersionError();
@@ -1520,7 +1526,9 @@ export class VoiceAssistantWebSocketServer {
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
forgeSearch: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true,
...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.