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
+2 -2
View File
@@ -64,7 +64,7 @@ You need at least one agent CLI installed and configured with your credentials:
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, open **Settings → your host → Connections → Pair a device**.
To connect from your phone, open **Settings → your host → Pair Device**.
### CLI / headless
@@ -75,7 +75,7 @@ npm install -g @getpaseo/cli
paseo
```
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
For full setup and configuration, see:
+1
View File
@@ -141,6 +141,7 @@ Enables remote access when the daemon is behind a firewall.
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
+1 -1
View File
@@ -189,7 +189,7 @@ Single file, validated with `PersistedConfigSchema`.
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
auth: { password: string } // bcrypt hash, optional
},
app: {
+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.
+5 -1
View File
@@ -200,9 +200,13 @@ Use `PASEO_HOME` to run multiple isolated daemon instances.
Get an offer URL from the daemon you want to control:
```bash
paseo daemon pair --json # prints { url, qr, ... }
paseo daemon pair # asks before enabling relay, then prints the QR and link
paseo daemon pair --relay # enables relay without prompting
paseo daemon pair --json # structured output; never prompts
```
Relay is off for new installations. In non-interactive or JSON mode, a disabled relay returns a `RELAY_DISABLED` error; pass `--relay` to provide explicit consent. Relay pairing is end-to-end encrypted. See [Security](/docs/security).
Use it from anywhere:
```bash
+19
View File
@@ -187,11 +187,30 @@ A `password=` in the URI always wins over the env var, so you can keep `PASEO_PA
In the mobile app, enter the password in the direct connection setup screen.
## Relay
New homes write `daemon.relay.enabled: false`. Paseo asks before enabling relay when you pair a device; existing homes keep their saved value. See [Security](/docs/security) for relay encryption and direct connection alternatives.
Set the persisted value in `config.json`:
```json
{
"daemon": {
"relay": {
"enabled": true
}
}
}
```
`PASEO_RELAY_ENABLED=true|false` overrides the persisted value for that daemon launch. The matching `paseo daemon start --relay` and `--no-relay` flags have the same authority. Remove the launch override before changing relay from Paseo Desktop or `paseo daemon pair --relay`.
## Common env vars
- `PASEO_HOME`, set Paseo home directory
- `PASEO_PASSWORD`, on the daemon, the password to require (plaintext, hashed at startup); on the CLI, the password used to connect when the host URI doesn't include one
- `PASEO_LISTEN`, override `daemon.listen`
- `PASEO_RELAY_ENABLED`, enable or disable the outbound relay for this daemon launch
- `PASEO_HOSTNAMES`, override/extend `daemon.hostnames`
- `PASEO_ALLOWED_HOSTS`, deprecated alias for `PASEO_HOSTNAMES`
- `PASEO_WEB_UI_ENABLED`, enable or disable the daemon-served web UI
+2 -2
View File
@@ -14,7 +14,7 @@ Paseo runs your coding agents on your machine and gives you a mobile, desktop, w
Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open it and you're done.
The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Connections → Pair a device**.
The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Pair Device**.
## Server / CLI
@@ -25,7 +25,7 @@ npm install -g @getpaseo/cli
paseo
```
Paseo prints a QR code in the terminal. Scan it from the mobile app, or enter the daemon address manually from another client.
Paseo starts the daemon locally, then asks whether to enable the end-to-end encrypted relay and print a pairing QR code. If you decline, enter the daemon address manually over TCP, Tailscale, or another VPN.
The daemon can also serve the browser web app itself, so you can use the full UI without the hosted app. See [Self-hosting the web UI](/docs/web-ui).
+2
View File
@@ -25,6 +25,8 @@ Clients connect to the daemon over WebSocket. There are two ways to establish th
The relay is the simplest way to connect from your phone. It requires no VPN setup, no port forwarding, and no firewall configuration. The daemon can stay bound to localhost or a socket file, it connects _outbound_ to the relay, and your phone meets it there. The official relay server is the open-source Elixir service at [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay).
Relay is off on new installations. When you pair a device from `paseo`, `paseo daemon pair`, or Paseo Desktop, Paseo asks before enabling it. Choosing not to enable relay leaves the daemon available for direct TCP, Tailscale, or other VPN connections and does not create a pairing QR code. Use `--relay` with the CLI pairing or startup command to opt in without an interactive prompt.
> **The relay is designed to be untrusted.** All traffic between your phone and daemon is end-to-end encrypted. The relay server cannot read your messages, see your code, or modify traffic without detection. Even if the relay is compromised, your data remains protected.
### How it works
+1 -1
View File
@@ -76,7 +76,7 @@ By default the daemon listens on `127.0.0.1:6767`, reachable only from the same
paseo daemon start --web-ui --listen 0.0.0.0:6767
```
> **Anyone who can reach the listening address can use your agents.** Before you bind beyond localhost, set a password and review your host allowlist. The relay (the default mobile pairing path) avoids this entirely by keeping the daemon bound to localhost, see [Security](/docs/security).
> **Anyone who can reach the listening address can use your agents.** Before you bind beyond localhost, set a password and review your host allowlist. The relay pairing path avoids this entirely by keeping the daemon bound to localhost, see [Security](/docs/security).
Two things to configure when you expose the daemon directly: