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

* feat: make relay opt-in for device pairing

* fix(relay): keep pairing state authoritative

* fix(relay): refresh pairing state across transports

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

* fix(relay): surface transport startup failures

* fix(pairing): gate offers for older daemons

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

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

* fix(relay): synchronize config during daemon startup

* fix(relay): preserve mutability for ignored overrides

* fix(cli): use reachable daemon for pairing

* fix(app): redirect invalid pair device routes

* fix(pairing): support remote host device pairing

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

* fix(pairing): validate pairing destinations

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

* fix(pairing): render QR codes across platforms

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

* fix(cli): pair from persisted relay state

Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
This commit is contained in:
Mohamed Boudra
2026-08-01 04:35:06 +08:00
committed by GitHub
parent 3778d0ad7b
commit d2de309fc3
73 changed files with 2163 additions and 543 deletions
@@ -1,7 +1,7 @@
import os from "node:os";
import http from "node:http";
import path from "node:path";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
@@ -12,8 +12,18 @@ import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
import type {
HubEnrollment,
HubEnrollmentResult,
HubRelationshipRemote,
HubRevocation,
HubSocketConnection,
HubSocketCredentials,
HubSocketEvents,
} from "./hub/relationship-remote.js";
interface HeldAgentClose {
started: Promise<void>;
@@ -224,6 +234,103 @@ describe("paseo daemon bootstrap", () => {
}
});
test("relay config changes during Hub enrollment reach the live runtime", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-startup-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
await mkdir(paseoHome, { recursive: true });
await writeFile(
path.join(paseoHome, "hub-relationship.json"),
`${JSON.stringify({
version: 1,
state: "pending",
relationship: {
daemonId: "daemon-startup-race",
idempotencyKey: "enrollment-startup-race",
hubOrigin: "https://hub.test",
createdAt: "2026-07-31T00:00:00.000Z",
scopes: ["hub.execution.*"],
},
credential: { secret: "credential" },
enrollment: { token: "enrollment-token" },
identity: { serverId: "server-startup-race", daemonPublicKey: "public-key" },
})}\n`,
"utf-8",
);
let markEnrollmentStarted: () => void = () => undefined;
const enrollmentStarted = new Promise<void>((resolve) => {
markEnrollmentStarted = resolve;
});
let releaseEnrollment: () => void = () => undefined;
const enrollmentReleased = new Promise<void>((resolve) => {
releaseEnrollment = resolve;
});
const remote: HubRelationshipRemote = {
async enroll(input: HubEnrollment): Promise<HubEnrollmentResult> {
markEnrollmentStarted();
await enrollmentReleased;
return {
daemonId: input.daemonId,
scopes: input.scopes,
webSocketUrl: "wss://hub.test/daemon",
};
},
async revoke(_input: HubRevocation): Promise<void> {},
openSocket(_input: HubSocketCredentials, _events: HubSocketEvents): HubSocketConnection {
return { close: () => undefined };
},
};
const config: PaseoDaemonConfig = {
listen: "127.0.0.1:0",
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: false,
relayEndpoint: "127.0.0.1:9",
relayUseTls: false,
appBaseUrl: "https://app.paseo.sh",
openai: undefined,
speech: undefined,
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }), {
hubRelationshipRemote: remote,
});
const starting = daemon.start();
let client: DaemonClient | null = null;
try {
await enrollmentStarted;
const listenTarget = daemon.getListenTarget();
if (!listenTarget || listenTarget.type !== "tcp") {
throw new Error("Expected daemon TCP listener during Hub enrollment");
}
client = new DaemonClient({
url: `ws://127.0.0.1:${listenTarget.port}/ws`,
appVersion: "0.1.82",
});
await client.connect();
await client.patchDaemonConfig({ relay: { enabled: true } });
releaseEnrollment();
await starting;
const status = await client.getDaemonStatus();
expect(status.relay?.enabled).toBe(true);
} finally {
releaseEnrollment();
await starting.catch(() => undefined);
await client?.close().catch(() => undefined);
await daemon.stop().catch(() => undefined);
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
});
test("stops new connections and agent registrations before closing agents", async () => {
const shutdown = await beginDaemonShutdownWithAgentClosing();
try {
+43 -42
View File
@@ -159,9 +159,8 @@ import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js";
import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
@@ -395,6 +394,7 @@ export interface PaseoDaemonConfig {
agentClients: Partial<Record<AgentProvider, AgentClient>>;
agentStoragePath: string;
relayEnabled?: boolean;
relayEnabledMutable?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
relayUseTls?: boolean;
@@ -449,6 +449,10 @@ export interface PaseoDaemonDependencies {
hubRelationshipClock?: HubRelationshipClock;
hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy;
createHubDaemonId?: () => string;
serverFeatureOverrides?: {
daemonStatusRpc?: boolean;
relayConfig?: boolean;
};
}
function createBootstrapManagedProcessRegistry(
@@ -507,6 +511,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
);
const initialConfig: MutableDaemonConfig = {
relay: { enabled: config.relayEnabled ?? true },
mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true },
browserTools: { enabled: config.browserToolsEnabled ?? false },
providers,
@@ -538,6 +543,7 @@ export async function createPaseoDaemon(
config.paseoHome,
createInitialMutableDaemonConfig(config),
logger,
{ relayEnabledMutable: config.relayEnabledMutable ?? true },
);
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({});
@@ -551,7 +557,7 @@ export async function createPaseoDaemon(
void reconcileManagedProcessLedger(managedProcesses, logger).catch((error) => {
logger.warn({ err: error }, "Failed to reconcile managed helper process ledger");
});
let relayTransport: RelayTransportController | null = null;
let relayRuntime: RelayRuntime | null = null;
const staticDir = config.staticDir;
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
@@ -1453,8 +1459,6 @@ export async function createPaseoDaemon(
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
if (boundListenTarget.type === "tcp") {
logger.info(
{
@@ -1489,7 +1493,12 @@ export async function createPaseoDaemon(
config.paseoHome,
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
{
allowedOrigins,
hostnames: configuredHostnames,
daemonStatusRpc: dependencies.serverFeatureOverrides?.daemonStatusRpc,
relayConfig: dependencies.serverFeatureOverrides?.relayConfig,
},
workspaceAutoName,
config.auth,
speechService,
@@ -1526,47 +1535,39 @@ export async function createPaseoDaemon(
worktreesRoot: config.worktreesRoot,
appBaseUrl: config.appBaseUrl,
desktopManaged: config.desktopManaged === true,
relay: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
getRelayConfig: () =>
relayRuntime?.getConfig() ?? {
enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
},
serviceProxyPublicBaseUrl,
browserToolsBroker,
hubRelationships,
);
relayRuntime = createRelayRuntime({
config: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
logger,
attachSocket: async (ws, metadata) => {
if (!wsServer) throw new Error("WebSocket server is not ready");
await wsServer.attachExternalSocket(ws, metadata);
},
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
daemonConfigStore.onFieldChange("relay.enabled", (value) => {
relayRuntime?.setEnabled(value === true);
});
await hubRelationships.start();
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: {
endpoint: relayPublicEndpoint,
useTls: relayPublicUseTls,
},
});
encodeOfferToFragmentUrl({ offer, appBaseUrl });
relayTransport?.stop().catch(() => undefined);
relayTransport = startRelayTransport({
logger,
attachSocket: (ws, metadata) => {
if (!wsServer) {
throw new Error("WebSocket server not initialized");
}
return wsServer.attachExternalSocket(ws, metadata);
},
relayEndpoint,
relayUseTls,
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
}
};
logAndResolve().then(resolve, reject);
@@ -1613,7 +1614,7 @@ export async function createPaseoDaemon(
terminalManager.killAll();
speechService.stop();
await scheduleService.stop().catch(() => undefined);
await relayTransport?.stop().catch(() => undefined);
await relayRuntime?.stop().catch(() => undefined);
if (wsServer) {
await wsServer.close();
}
@@ -21,6 +21,44 @@ describe("daemon relay config", () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("preserves implicit relay-on for a legacy config without enabled", async () => {
const home = await createPaseoHome({ version: 1, daemon: { relay: {} } });
expect(loadConfig(home, { env: {} }).relayEnabled).toBe(true);
});
test("keeps explicit persisted relay state and marks it mutable", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { relay: { enabled: false } },
});
const config = loadConfig(home, { env: {} });
expect(config.relayEnabled).toBe(false);
expect(config.relayEnabledMutable).toBe(true);
});
test("marks environment relay overrides immutable", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { relay: { enabled: false } },
});
const config = loadConfig(home, { env: { PASEO_RELAY_ENABLED: "true" } });
expect(config.relayEnabled).toBe(true);
expect(config.relayEnabledMutable).toBe(false);
});
test.each(["", "treu"])(
"ignores invalid relay override %j without locking config",
async (value) => {
const home = await createPaseoHome({
version: 1,
daemon: { relay: { enabled: false } },
});
const config = loadConfig(home, { env: { PASEO_RELAY_ENABLED: value } });
expect(config.relayEnabled).toBe(false);
expect(config.relayEnabledMutable).toBe(true);
},
);
test("loads relay TLS from env, persisted config, and hosted relay fallback", async () => {
const persistedHome = await createPaseoHome({
version: 1,
+14 -5
View File
@@ -187,6 +187,7 @@ interface ResolveRelayInput {
interface ResolvedRelay {
enabled: boolean;
enabledMutable: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
@@ -210,11 +211,11 @@ function resolveTlsFromEnv(
}
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED);
// COMPAT(relayOptInDefault): configs created before v0.2.6 may omit this field.
// Preserve their relay-on behavior until 2027-01-31; new homes materialize false.
const enabled =
input.cliRelayEnabled ??
parseBooleanEnv(input.env.PASEO_RELAY_ENABLED) ??
input.persisted.daemon?.relay?.enabled ??
true;
input.cliRelayEnabled ?? environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? true;
const endpoint =
input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ??
@@ -235,7 +236,14 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
input.persisted.daemon?.relay?.publicUseTls,
useTls,
);
return { enabled, endpoint, publicEndpoint, useTls, publicUseTls };
return {
enabled,
enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined,
endpoint,
publicEndpoint,
useTls,
publicUseTls,
};
}
interface ResolvedVoiceLlm {
@@ -506,6 +514,7 @@ export function loadConfig(
staticDir: "public",
agentClients: {},
relayEnabled: relay.enabled,
relayEnabledMutable: relay.enabledMutable,
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
@@ -63,6 +63,110 @@ describe("DaemonConfigStore", () => {
}
});
test("patch persists relay state and emits its field change", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
const changes: unknown[] = [];
store.onFieldChange("relay.enabled", (value) => changes.push(value));
store.patch({ relay: { enabled: true } });
expect(changes).toEqual([true]);
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("rolls back config when a field transition fails", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
store.onFieldChange("relay.enabled", (enabled) => {
if (enabled === true) {
throw new Error("Relay transport failed to start");
}
});
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
"Relay transport failed to start",
);
expect(store.get().relay?.enabled).toBe(false);
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("rejects relay patches when a launch override owns the setting", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
{ relayEnabledMutable: false },
);
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
"Relay is controlled by a daemon launch override",
);
});
test("unrelated patches do not persist a one-launch relay override", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const persisted = loadPersistedConfig(paseoHome);
writeFileSync(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
...persisted,
daemon: { ...persisted.daemon, relay: { enabled: false } },
})}\n`,
);
const store = new DaemonConfigStore(
paseoHome,
{
relay: { enabled: true },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
{ relayEnabledMutable: false },
);
store.patch({ browserTools: { enabled: true } });
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("patch persists provider enabled flags into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -27,6 +27,11 @@ export interface DaemonConfigChangeDetails {
type ConfigListener = (config: MutableDaemonConfig, details: DaemonConfigChangeDetails) => void;
type FieldChangeHandler = (value: unknown) => void;
interface AppliedFieldChange {
handler: FieldChangeHandler;
previousValue: unknown;
}
function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
return logger?.child({ module: "daemon-config-store" });
}
@@ -162,11 +167,21 @@ export class DaemonConfigStore {
private readonly logger: LoggerLike | undefined;
private readonly changeListeners = new Set<ConfigListener>();
private readonly fieldChangeHandlers = new Map<string, Set<FieldChangeHandler>>();
private readonly relayEnabledMutable: boolean;
constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) {
constructor(
paseoHome: string,
initial: MutableDaemonConfig,
logger?: LoggerLike,
options: { relayEnabledMutable?: boolean } = {},
) {
this.paseoHome = paseoHome;
this.logger = getLogger(logger);
this.current = MutableDaemonConfigSchema.parse(initial);
this.current = MutableDaemonConfigSchema.parse({
...initial,
relay: initial.relay ?? { enabled: true },
});
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
}
public get(): MutableDaemonConfig {
@@ -175,6 +190,11 @@ export class DaemonConfigStore {
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
throw new Error(
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
);
}
const { removeProviders = [], ...configPatch } = parsedPatch;
const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch);
@@ -194,24 +214,32 @@ export class DaemonConfigStore {
return this.current;
}
// Persist before updating in-memory state so that if persistence fails,
// runtime and disk stay consistent.
this.persistConfig(next, removedProviders);
if (!configChanged) {
return this.current;
}
const persistedBeforePatch = this.persistConfig(next, removedProviders);
if (!configChanged) return this.current;
const previous = this.current;
const appliedFieldChanges: AppliedFieldChange[] = [];
this.current = next;
for (const path of changedFieldPaths) {
const handlers = this.fieldChangeHandlers.get(path);
if (!handlers) {
continue;
try {
for (const path of changedFieldPaths) {
const handlers = this.fieldChangeHandlers.get(path);
if (!handlers) {
continue;
}
const value = getValueAtPath(next, path);
const previousValue = getValueAtPath(previous, path);
for (const handler of handlers) {
appliedFieldChanges.push({ handler, previousValue });
handler(value);
}
}
const value = getValueAtPath(next, path);
for (const handler of handlers) {
handler(value);
} catch (error) {
this.current = previous;
for (const change of appliedFieldChanges.toReversed()) {
change.handler(change.previousValue);
}
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
throw error;
}
const changeDetails: DaemonConfigChangeDetails = { removedProviders };
@@ -246,14 +274,19 @@ export class DaemonConfigStore {
};
}
private persistConfig(config: MutableDaemonConfig, removeProviders: readonly string[]): void {
private persistConfig(
config: MutableDaemonConfig,
removeProviders: readonly string[],
): PersistedConfig {
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const nextPersisted = mergeMutableConfigIntoPersistedConfig({
persisted,
mutable: config,
removeProviders,
persistRelayEnabled: this.relayEnabledMutable,
});
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
return persisted;
}
}
@@ -261,8 +294,12 @@ function mergeMutableConfigIntoPersistedConfig(params: {
persisted: PersistedConfig;
mutable: MutableDaemonConfig;
removeProviders: readonly string[];
persistRelayEnabled: boolean;
}): PersistedConfig {
const { persisted, mutable, removeProviders } = params;
const { persisted, mutable, removeProviders, persistRelayEnabled } = params;
if (!mutable.relay) {
throw new Error("Mutable daemon config is missing relay state");
}
const browserToolsEnabled = readBrowserToolsEnabled(mutable);
const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
const persistedProviderOverrides = omitProvidersFromOverrides(
@@ -300,6 +337,14 @@ function mergeMutableConfigIntoPersistedConfig(params: {
...persisted,
daemon: {
...persisted.daemon,
...(persistRelayEnabled
? {
relay: {
...persisted.daemon?.relay,
enabled: mutable.relay.enabled,
},
}
: {}),
mcp: {
...persisted.daemon?.mcp,
injectIntoAgents: mutable.mcp.injectIntoAgents,
@@ -84,8 +84,13 @@ function bootstrapFromEnvironment(): BootstrapResult {
}
function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
if (process.argv.includes("--relay")) {
config.relayEnabled = true;
config.relayEnabledMutable = false;
}
if (process.argv.includes("--no-relay")) {
config.relayEnabled = false;
config.relayEnabledMutable = false;
}
if (process.argv.includes("--relay-use-tls")) {
config.relayUseTls = true;
@@ -648,6 +648,16 @@ describe("PersistedConfigSchema voice mode config", () => {
});
describe("loadPersistedConfig", () => {
test("materializes relay disabled for a new Paseo home", () => {
const home = createTempHome();
try {
const config = loadPersistedConfig(home);
expect(config.daemon?.relay?.enabled).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("accepts the documented config schema marker", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
@@ -334,7 +334,7 @@ const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({
allowedOrigins: ["https://app.paseo.sh"],
},
relay: {
enabled: true,
enabled: false,
},
},
app: {
@@ -0,0 +1,64 @@
import { describe, expect, test, vi } from "vitest";
import pino from "pino";
import { generateKeyPair } from "@getpaseo/relay";
import { createRelayRuntime } from "./relay-runtime.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
describe("RelayRuntime", () => {
test("starts and stops transport as enabled state changes", async () => {
const stops: Array<ReturnType<typeof vi.fn>> = [];
const starts: string[] = [];
const startTransport: typeof startRelayTransport = (options) => {
starts.push(options.relayEndpoint);
const stop = vi.fn(async () => undefined);
stops.push(stop);
return { stop } satisfies RelayTransportController;
};
const runtime = createRelayRuntime({
config: {
enabled: false,
endpoint: "relay.example.test:443",
publicEndpoint: "relay.example.test:443",
useTls: true,
publicUseTls: true,
},
logger: pino({ level: "silent" }),
attachSocket: async () => undefined,
serverId: "relay-runtime-test",
daemonKeyPair: generateKeyPair(),
startTransport,
});
expect(starts).toEqual([]);
runtime.setEnabled(true);
runtime.setEnabled(true);
expect(starts).toEqual(["relay.example.test:443"]);
expect(runtime.getConfig().enabled).toBe(true);
runtime.setEnabled(false);
await vi.waitFor(() => expect(stops[0]).toHaveBeenCalledOnce());
expect(runtime.getConfig().enabled).toBe(false);
});
test("keeps relay disabled when transport startup fails", () => {
const runtime = createRelayRuntime({
config: {
enabled: false,
endpoint: "invalid-endpoint",
publicEndpoint: "invalid-endpoint",
useTls: false,
publicUseTls: false,
},
logger: pino({ level: "silent" }),
attachSocket: async () => undefined,
serverId: "relay-runtime-test",
daemonKeyPair: generateKeyPair(),
startTransport: () => {
throw new Error("Invalid relay endpoint");
},
});
expect(() => runtime.setEnabled(true)).toThrow("Invalid relay endpoint");
expect(runtime.getConfig().enabled).toBe(false);
});
});
@@ -0,0 +1,78 @@
import type pino from "pino";
import type { KeyPair } from "@getpaseo/relay/e2ee";
import type { ExternalSocketMetadata } from "./websocket-server.js";
import {
startRelayTransport,
type RelaySocketLike,
type RelayTransportController,
} from "./relay-transport.js";
export interface RelayRuntimeConfig {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
}
interface RelayRuntimeOptions {
config: RelayRuntimeConfig;
logger: pino.Logger;
attachSocket(ws: RelaySocketLike, metadata?: ExternalSocketMetadata): Promise<void>;
serverId: string;
daemonKeyPair: KeyPair;
startTransport?: typeof startRelayTransport;
}
export interface RelayRuntime {
getConfig(): RelayRuntimeConfig;
setEnabled(enabled: boolean): void;
stop(): Promise<void>;
}
export function createRelayRuntime(options: RelayRuntimeOptions): RelayRuntime {
const startTransport = options.startTransport ?? startRelayTransport;
let config = options.config;
let transport: RelayTransportController | null = null;
function start(): void {
if (transport) return;
transport = startTransport({
logger: options.logger,
attachSocket: options.attachSocket,
relayEndpoint: config.endpoint,
relayUseTls: config.useTls,
serverId: options.serverId,
daemonKeyPair: options.daemonKeyPair,
});
}
function setEnabled(enabled: boolean): void {
if (config.enabled === enabled) return;
if (enabled) {
start();
config = { ...config, enabled: true };
return;
}
config = { ...config, enabled: false };
const current = transport;
transport = null;
void current?.stop().catch((error) => {
options.logger.warn({ err: error }, "Failed to stop relay transport");
});
}
async function stop(): Promise<void> {
const current = transport;
transport = null;
await current?.stop();
}
if (config.enabled) start();
return {
getConfig: () => config,
setEnabled,
stop,
};
}
@@ -11,7 +11,7 @@ import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import type { ExternalSocketMetadata } from "./websocket-server.js";
import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js";
interface RelayTransportOptions {
export interface RelayTransportOptions {
logger: pino.Logger;
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>;
relayEndpoint: string; // "host:port"
@@ -25,7 +25,7 @@ export interface RelayTransportController {
stop: () => Promise<void>;
}
interface RelaySocketLike {
export interface RelaySocketLike {
readyState: number;
bufferedAmount?: number;
send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void;
+4 -4
View File
@@ -1425,7 +1425,7 @@ describe("daemon status + pairing RPC", () => {
paseoHome: makeHome(),
serverId: "srv-test",
daemonVersion: "9.9.9",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
agentManager: {
listProviderAvailability: vi.fn().mockResolvedValue([
{ provider: "claude", available: true },
@@ -1464,7 +1464,7 @@ describe("daemon status + pairing RPC", () => {
paseoHome: makeHome(),
serverId: "srv-test",
daemonVersion: "9.9.9",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
agentManager: {
listProviderAvailability: vi.fn().mockRejectedValue(new Error("provider listing failed")),
},
@@ -1500,13 +1500,13 @@ describe("daemon status + pairing RPC", () => {
paseoHome: makeHome(),
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
relay: {
getRelayConfig: () => ({
enabled: false,
endpoint: "relay.paseo.sh:443",
publicEndpoint: "relay.paseo.sh:443",
useTls: true,
publicUseTls: true,
},
}),
},
});
@@ -127,7 +127,7 @@ describe("DaemonSession", () => {
const { subsystem, emitted } = makeSubsystem({
serverId: "srv-1",
daemonVersion: "1.2.3",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
listProviderAvailability: async () => [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "boom" },
@@ -161,7 +161,7 @@ describe("DaemonSession", () => {
const { subsystem, emitted } = makeSubsystem({
serverId: "srv-1",
daemonVersion: "1.2.3",
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
listProviderAvailability: async () => {
throw new Error("provider listing failed");
},
@@ -191,13 +191,13 @@ describe("DaemonSession", () => {
const { subsystem, emitted } = makeSubsystem({
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
relay: {
getRelayConfig: () => ({
enabled: false,
endpoint: "relay.paseo.sh:443",
publicEndpoint: "relay.paseo.sh:443",
useTls: true,
publicUseTls: true,
},
}),
},
});
@@ -219,13 +219,13 @@ describe("DaemonSession", () => {
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
appBaseUrl: "https://app.example.test",
relay: {
getRelayConfig: () => ({
enabled: true,
endpoint: "relay.example.test:443",
publicEndpoint: "relay.example.test:443",
useTls: true,
publicUseTls: true,
},
}),
},
});
@@ -246,19 +246,53 @@ describe("DaemonSession", () => {
expect(typeof message.payload.qr).toBe("string");
});
test("pairing offer reads relay state at request time", async () => {
let enabled = false;
const { subsystem, emitted } = makeSubsystem({
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
appBaseUrl: "https://app.example.test",
getRelayConfig: () => ({
enabled,
endpoint: "relay.example.test:443",
publicEndpoint: "relay.example.test:443",
useTls: true,
publicUseTls: true,
}),
},
});
await subsystem.handleGetPairingOfferRequest({
type: "daemon.get_pairing_offer.request",
requestId: "disabled",
});
enabled = true;
await subsystem.handleGetPairingOfferRequest({
type: "daemon.get_pairing_offer.request",
requestId: "enabled",
});
const pairingResponses = emitted.filter(
(message) => message.type === "daemon.get_pairing_offer.response",
);
expect(pairingResponses[0]?.payload.relayEnabled).toBe(false);
expect(pairingResponses[1]?.payload.relayEnabled).toBe(true);
expect(pairingResponses[1]?.payload.url).toContain("#offer=");
});
test("diagnostics includes a log tail and redacts connection secrets", async () => {
const { subsystem, emitted, paseoHome } = makeSubsystem({
serverId: "srv-1",
daemonVersion: "1.2.3",
daemonRuntimeConfig: {
listen: "127.0.0.1:6767",
relay: {
getRelayConfig: () => ({
enabled: true,
endpoint: "relay.secret.test:443",
publicEndpoint: "relay.secret.test:443",
useTls: true,
publicUseTls: true,
},
}),
},
});
writeFileSync(
@@ -17,7 +17,7 @@ export interface DaemonRuntimeConfig {
worktreesRoot?: string;
appBaseUrl?: string;
desktopManaged?: boolean;
relay: {
getRelayConfig(): {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
@@ -169,7 +169,7 @@ export class DaemonSession {
nodePath: process.execPath,
startedAt: pidInfo?.startedAt ?? null,
listen: this.daemonRuntimeConfig?.listen ?? null,
relay: this.daemonRuntimeConfig?.relay ?? null,
relay: this.daemonRuntimeConfig?.getRelayConfig() ?? null,
providers,
},
});
@@ -196,10 +196,10 @@ export class DaemonSession {
msg: Extract<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
): Promise<void> {
try {
const relay = this.daemonRuntimeConfig?.relay;
const relay = this.daemonRuntimeConfig?.getRelayConfig();
const pairing = await generateLocalPairingOffer({
paseoHome: this.paseoHome,
relayEnabled: relay?.enabled ?? true,
relayEnabled: relay?.enabled ?? false,
relayEndpoint: relay?.endpoint,
relayPublicEndpoint: relay?.publicEndpoint,
relayUseTls: relay?.useTls,
@@ -133,7 +133,7 @@ function collectProcessEntries(options: DaemonDiagnosticsOptions): DiagnosticEnt
}
function collectRuntimeConfigEntries(options: DaemonDiagnosticsOptions): DiagnosticEntry[] {
const relay = options.daemonRuntimeConfig?.relay ?? null;
const relay = options.daemonRuntimeConfig?.getRelayConfig() ?? null;
return [
{ label: "Listen", value: formatListenKind(options.daemonRuntimeConfig?.listen ?? null) },
{ label: "Relay enabled", value: relay ? String(relay.enabled) : "false" },
@@ -536,8 +536,8 @@ export function redactDiagnostic(
let redacted = value;
const sensitiveValues = [
options?.daemonRuntimeConfig?.listen,
options?.daemonRuntimeConfig?.relay?.endpoint,
options?.daemonRuntimeConfig?.relay?.publicEndpoint,
options?.daemonRuntimeConfig?.getRelayConfig()?.endpoint,
options?.daemonRuntimeConfig?.getRelayConfig()?.publicEndpoint,
].filter((item): item is string => Boolean(item));
for (const sensitive of sensitiveValues) {
@@ -12,6 +12,8 @@ async function main(): Promise<void> {
corsAllowedOrigins: [`http://localhost:${metroPort}`],
daemonVersion: "0.0.0",
desktopManaged: process.env.E2E_DESKTOP_MANAGED === "1",
daemonStatusRpcCapability: process.env.E2E_DAEMON_STATUS_RPC_CAPABILITY !== "0",
relayConfigCapability: process.env.E2E_RELAY_CONFIG_CAPABILITY !== "0",
});
const serverId = (await readFile(path.join(daemon.paseoHome, "server-id"), "utf8")).trim();
@@ -27,6 +27,8 @@ interface TestPaseoDaemonOptions {
relayEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
daemonStatusRpcCapability?: boolean;
relayConfigCapability?: boolean;
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
providerOverrides?: PaseoDaemonConfig["providerOverrides"];
paseoHomeRoot?: string;
@@ -92,7 +94,12 @@ export async function createTestPaseoDaemon(
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options);
const logger = options.logger ?? pino({ level: "silent" });
const daemon = await createPaseoDaemon(config, logger);
const daemon = await createPaseoDaemon(config, logger, {
serverFeatureOverrides: {
daemonStatusRpc: options.daemonStatusRpcCapability,
relayConfig: options.relayConfigCapability,
},
});
try {
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
const listenTarget = daemon.getListenTarget();
@@ -131,6 +131,8 @@ interface WebSocketConnectionIdentity {
interface WebSocketServerConfig {
allowedOrigins: Set<string>;
hostnames?: HostnamesConfig;
daemonStatusRpc?: boolean;
relayConfig?: boolean;
}
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
@@ -554,6 +556,8 @@ export class VoiceAssistantWebSocketServer {
private readonly hubRelationships: HubRelationshipManagement | null;
private readonly browserToolsRegistrations = new Map<string, BrowserToolsRegistration>();
private acceptingConnections = true;
private readonly advertiseDaemonStatusRpc: boolean;
private readonly advertiseRelayConfig: boolean;
constructor(
server: HTTPServer,
@@ -601,6 +605,8 @@ export class VoiceAssistantWebSocketServer {
hubRelationships?: HubRelationshipManagement | null,
) {
this.logger = logger.child({ module: "websocket-server" });
this.advertiseDaemonStatusRpc = wsConfig.daemonStatusRpc !== false;
this.advertiseRelayConfig = wsConfig.relayConfig !== false;
this.serverId = serverId;
if (typeof daemonVersion !== "string" || daemonVersion.trim().length === 0) {
throw new MissingDaemonVersionError();
@@ -1520,7 +1526,9 @@ export class VoiceAssistantWebSocketServer {
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
forgeSearch: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true,
...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.