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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: "منقول",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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: "コピーしました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Скопировано",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -509,6 +509,7 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
|
||||
export const HOST_SECTION_SLUGS = [
|
||||
"projects",
|
||||
"connections",
|
||||
"pair-device",
|
||||
"agents",
|
||||
"workspaces",
|
||||
"providers",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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://") ||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user