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
View File
@@ -139,6 +139,7 @@ export function createCli(): Command {
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.option("--port <port>", "Port for restarted daemon listen target")
.option("--relay", "Enable relay on restarted daemon")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
@@ -40,6 +40,7 @@ export function createDaemonCommand(): Command {
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.option("--port <port>", "Port for restarted daemon listen target")
.option("--relay", "Enable relay on restarted daemon")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents")
@@ -128,6 +128,9 @@ function envWithHome(home?: string): NodeJS.ProcessEnv {
function buildRunnerArgs(options: DaemonStartOptions): string[] {
const args: string[] = [];
if (options.relay === true) {
args.push("--relay");
}
if (options.relay === false) {
args.push("--no-relay");
}
@@ -0,0 +1,139 @@
import { describe, expect, test, vi } from "vitest";
import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js";
const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null };
const enabledOffer: PairingOffer = {
relayEnabled: true,
url: "https://app.paseo.sh/#offer=test",
qr: null,
};
interface RecordedPairCommandOutput extends PairCommandOutput {
stdout: string[];
stderr: string[];
successes: string[];
exitCode: number | undefined;
}
function createRecordedOutput(): RecordedPairCommandOutput {
return {
columns: 80,
stdout: [],
stderr: [],
successes: [],
exitCode: undefined,
writeStdout(message) {
this.stdout.push(message);
},
writeStderr(message) {
this.stderr.push(message);
},
setExitCode(code) {
this.exitCode = code;
},
success(message) {
this.successes.push(message);
},
};
}
describe("daemon pair workflow", () => {
test("interactive decline prints direct guidance and creates no pairing output", async () => {
const resolveOffer = vi.fn(async () => disabledOffer);
const confirmRelay = vi.fn(async () => false);
const printDirectGuidance = vi.fn();
const output = createRecordedOutput();
await runPairCommand(
{},
{ resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output },
);
expect(confirmRelay).toHaveBeenCalledOnce();
expect(printDirectGuidance).toHaveBeenCalledOnce();
expect(output.stderr.join("")).toContain("No pairing QR was created");
expect(output.exitCode).toBe(1);
});
test("interactive consent enables relay and prints the refreshed offer", async () => {
const resolveOffer = vi
.fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise<PairingOffer>>()
.mockResolvedValueOnce(disabledOffer)
.mockResolvedValueOnce(enabledOffer);
const output = createRecordedOutput();
await runPairCommand(
{},
{
resolveOffer,
confirmRelay: async () => true,
printDirectGuidance: vi.fn(),
isInteractive: () => true,
output,
},
);
expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true }));
expect(output.stdout.join("")).toContain(enabledOffer.url ?? "");
expect(output.successes).toEqual(["Relay enabled"]);
});
test("JSON mode never prompts and returns a structured relay-disabled error", async () => {
const confirmRelay = vi.fn(async () => true);
const output = createRecordedOutput();
await runPairCommand(
{ json: true },
{
resolveOffer: async () => disabledOffer,
confirmRelay,
printDirectGuidance: vi.fn(),
isInteractive: () => true,
output,
},
);
expect(confirmRelay).not.toHaveBeenCalled();
expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"');
expect(output.exitCode).toBe(1);
});
test("explicit relay opts in without prompting", async () => {
const resolveOffer = vi.fn(async () => enabledOffer);
const confirmRelay = vi.fn(async () => false);
const output = createRecordedOutput();
await runPairCommand(
{ relay: true, json: true },
{
resolveOffer,
confirmRelay,
printDirectGuidance: vi.fn(),
isInteractive: () => false,
output,
},
);
expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true }));
expect(confirmRelay).not.toHaveBeenCalled();
expect(output.exitCode).toBeUndefined();
});
test("surfaces launch-override rejection", async () => {
await expect(
runPairCommand(
{ relay: true },
{
resolveOffer: async () => {
throw new Error("Relay is controlled by a daemon launch override");
},
confirmRelay: vi.fn(),
printDirectGuidance: vi.fn(),
isInteractive: () => false,
output: createRecordedOutput(),
},
),
).rejects.toThrow("launch override");
});
});
+186 -51
View File
@@ -1,66 +1,97 @@
import { confirm, isCancel, log } from "@clack/prompts";
import { Command } from "commander";
import chalk from "chalk";
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server";
import {
generateLocalPairingOffer,
getOrCreateServerId,
loadConfig,
resolvePaseoHome,
} from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveLocalDaemonState } from "./local-daemon.js";
import { addJsonOption } from "../../utils/command-options.js";
import { formatPairingInstructions } from "../../output/pairing.js";
interface PairOptions {
home?: string;
json?: boolean;
relay?: boolean;
}
export interface PairCommandDependencies {
resolveOffer: typeof resolveLocalPairingOffer;
confirmRelay: typeof confirmRelayPairing;
printDirectGuidance: typeof printDirectConnectionGuidance;
isInteractive: () => boolean;
output: PairCommandOutput;
}
export interface PairCommandOutput {
columns: number | undefined;
writeStdout(message: string): void;
writeStderr(message: string): void;
setExitCode(code: number): void;
success(message: string): void;
}
export interface PairingOffer {
relayEnabled: boolean;
url: string | null;
qr: string | null;
}
const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500;
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
function createProcessOutput(): PairCommandOutput {
return {
columns: process.stdout.columns,
writeStdout(message) {
process.stdout.write(message);
},
writeStderr(message) {
process.stderr.write(message);
},
setExitCode(code) {
process.exitCode = code;
},
success(message) {
log.success(message);
},
};
}
export function pairCommand(): Command {
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--relay", "Enable relay without prompting")
.action(async (_options: PairOptions, command: Command) => {
await runPairCommand(command.optsWithGlobals());
});
}
export async function runPairCommand(options: PairOptions): Promise<void> {
if (options.home) {
process.env.PASEO_HOME = options.home;
export async function resolveLocalPairingOffer(options: {
paseoHome: string;
enableRelay?: boolean;
}): Promise<PairingOffer> {
const state = resolveLocalDaemonState({ home: options.paseoHome });
const serverId = getOrCreateServerId(state.home);
const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay);
if (daemonOffer) return daemonOffer;
if (state.running) {
throw new Error(
"The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.",
);
}
const paseoHome = resolvePaseoHome();
const state = resolveLocalDaemonState({ home: paseoHome });
const host = resolveTcpHostFromListen(state.listen);
// Try to get the pairing offer from the running daemon first.
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
const supportsDaemonStatusRpc =
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
if (supportsDaemonStatusRpc) {
try {
const offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
await client.close().catch(() => {});
outputPairingResult(
{ relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null },
options,
);
return;
} catch {
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
// v0.1.76; pairing should come from daemon.get_pairing_offer.
}
}
await client.close().catch(() => {});
}
const config = loadConfig(options.paseoHome);
if (options.enableRelay && !config.relayEnabled) {
throw new Error("Start the daemon before enabling relay for pairing.");
}
// Fall back to local pairing offer generation.
const config = loadConfig(paseoHome);
const pairing = await generateLocalPairingOffer({
paseoHome,
return generateLocalPairingOffer({
paseoHome: options.paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
@@ -69,28 +100,132 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
appBaseUrl: config.appBaseUrl,
includeQr: true,
});
}
outputPairingResult(pairing, options);
async function resolveDaemonPairingOffer(
listen: string,
expectedServerId: string,
enableRelay: boolean | undefined,
): Promise<PairingOffer | null> {
const client = await tryConnectToDaemon({
host: listen,
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
if (!client) return null;
try {
const serverInfo = client.getLastServerInfoMessage();
if (serverInfo?.serverId.trim() !== expectedServerId) {
throw new Error(
"The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.",
);
}
if (serverInfo?.features?.daemonStatusRpc !== true) {
throw new Error("Update the Paseo daemon before pairing from this command.");
}
let offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
if (!offer.relayEnabled && enableRelay) {
if (serverInfo.features.relayConfig !== true) {
throw new Error("Update the Paseo daemon before enabling relay from this command.");
}
await client.patchDaemonConfig({ relay: { enabled: true } });
offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
}
return {
relayEnabled: offer.relayEnabled,
url: offer.url || null,
qr: offer.qr ?? null,
};
} finally {
await client.close().catch(() => undefined);
}
}
export async function confirmRelayPairing(): Promise<boolean> {
log.message("Your connection is end-to-end encrypted. Paseo cannot read your code or messages.");
log.message(`Learn how it works: ${RELAY_DOCS_URL}`);
const answer = await confirm({
message: "Enable relay to pair a device?",
initialValue: false,
});
return !isCancel(answer) && answer;
}
export function printDirectConnectionGuidance(): void {
console.log("Daemon is running with relay off.");
console.log(
"To connect another device directly, use the daemon's TCP address over your LAN, Tailscale, or another VPN.",
);
console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`);
}
export async function runPairCommand(
options: PairOptions,
dependencyOverrides: Partial<PairCommandDependencies> = {},
): Promise<void> {
if (options.home) process.env.PASEO_HOME = options.home;
const dependencies: PairCommandDependencies = {
resolveOffer: resolveLocalPairingOffer,
confirmRelay: confirmRelayPairing,
printDirectGuidance: printDirectConnectionGuidance,
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
output: createProcessOutput(),
...dependencyOverrides,
};
const paseoHome = resolvePaseoHome();
let pairing = await dependencies.resolveOffer({
paseoHome,
enableRelay: options.relay === true,
});
const canPrompt = dependencies.isInteractive() && options.json !== true;
if (!pairing.relayEnabled && canPrompt) {
const shouldEnable = await dependencies.confirmRelay();
if (!shouldEnable) {
dependencies.printDirectGuidance();
dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`);
dependencies.output.setExitCode(1);
return;
}
pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true });
dependencies.output.success("Relay enabled");
}
outputPairingResult(pairing, options, dependencies.output);
}
function outputPairingResult(
pairing: { relayEnabled: boolean; url: string | null; qr: string | null },
pairing: PairingOffer,
options: PairOptions,
output: PairCommandOutput,
): void {
if (!pairing.relayEnabled || !pairing.url) {
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
console.error(chalk.yellow("Enable relay and run this command again."));
process.exit(1);
if (options.json) {
output.writeStderr(
`${JSON.stringify({
code: "RELAY_DISABLED",
message: "Relay pairing is disabled for this daemon.",
action: "Run paseo daemon pair --relay --json to enable it explicitly.",
})}\n`,
);
} else {
output.writeStderr(`${chalk.red("Relay pairing is disabled for this daemon.")}\n`);
output.writeStderr(`${chalk.yellow("Run paseo daemon pair --relay to enable it.")}\n`);
}
output.setExitCode(1);
return;
}
if (options.json) {
process.stdout.write(
output.writeStdout(
`${JSON.stringify(
{
relayEnabled: pairing.relayEnabled,
url: pairing.url,
qr: pairing.qr,
},
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr },
null,
2,
)}\n`,
@@ -98,11 +233,11 @@ function outputPairingResult(
return;
}
process.stdout.write(
output.writeStdout(
formatPairingInstructions({
url: pairing.url,
qr: pairing.qr,
columns: process.stdout.columns,
columns: output.columns,
}),
);
}
@@ -20,6 +20,7 @@ export function startCommand(): Command {
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--foreground", "Run in foreground (don't daemonize)")
.option("--relay", "Enable relay connection")
.option("--no-relay", "Disable relay connection")
.option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { selectRelayStatus } from "./status.js";
describe("selectRelayStatus", () => {
const persisted = {
enabled: false,
endpoint: "persisted.internal:443",
publicEndpoint: "persisted.example.com:443",
useTls: true,
publicUseTls: true,
};
test("uses the running daemon relay state over persisted config", () => {
expect(
selectRelayStatus({
persisted,
live: {
enabled: true,
endpoint: "live.internal:443",
publicEndpoint: "live.example.com:443",
useTls: true,
publicUseTls: true,
},
}),
).toBe("wss://live.example.com:443");
});
test("falls back to persisted config when the daemon cannot report live state", () => {
expect(selectRelayStatus({ persisted })).toBe("disabled");
});
});
+64 -21
View File
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveLocalDaemonState } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500;
@@ -204,6 +204,7 @@ interface DaemonProbeResult {
daemonVersion?: string | null;
daemonNodeOverride?: string;
daemonProviders?: ProviderBinaryStatus[];
relayStatus?: string;
note?: string;
}
@@ -262,6 +263,13 @@ async function probeDaemonOverWebsocket(args: {
version: p.available ? null : (p.error ?? null),
source: "daemon" as const,
}));
const relayStatus =
statusPayload.relay == null
? undefined
: selectRelayStatus({
persisted: relayConfigFromLocalState(state),
live: statusPayload.relay,
});
if (!state.running) {
return {
@@ -269,6 +277,7 @@ async function probeDaemonOverWebsocket(args: {
daemonVersion: statusPayload.version ?? daemonVersion,
daemonNodeOverride: statusPayload.nodePath,
daemonProviders,
relayStatus,
note: state.pidInfo
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
@@ -280,6 +289,7 @@ async function probeDaemonOverWebsocket(args: {
daemonVersion: statusPayload.version ?? daemonVersion,
daemonNodeOverride: statusPayload.nodePath,
daemonProviders,
relayStatus,
};
} catch {
return {
@@ -301,6 +311,7 @@ interface ProbeMergeState {
daemonNode: string;
daemonVersion: string | null;
daemonProviders: ProviderBinaryStatus[] | undefined;
relayStatus: string;
note: string | undefined;
}
@@ -312,6 +323,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "prob
daemonNode: probe.daemonNodeOverride ?? input.daemonNode,
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
relayStatus: probe.relayStatus ?? input.relayStatus,
note: probe.note ? appendNote(input.note, probe.note) : input.note,
};
}
@@ -336,10 +348,34 @@ async function resolveDaemonNodeLabel(
return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
}
function formatRelayStatus(state: ReturnType<typeof resolveLocalDaemonState>): string {
if (!state.relayEnabled) return "disabled";
const scheme = state.relayPublicUseTls ? "wss" : "ws";
return `${scheme}://${state.relayEndpoint}`;
interface RelayStatusConfig {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
}
function relayConfigFromLocalState(
state: ReturnType<typeof resolveLocalDaemonState>,
): RelayStatusConfig {
return {
enabled: state.relayEnabled,
endpoint: state.relayEndpoint,
publicEndpoint: state.relayEndpoint,
useTls: state.relayUseTls,
publicUseTls: state.relayPublicUseTls,
};
}
export function selectRelayStatus(input: {
persisted: RelayStatusConfig;
live?: RelayStatusConfig;
}): string {
const relay = input.live ?? input.persisted;
if (!relay.enabled) return "disabled";
const scheme = relay.publicUseTls ? "wss" : "ws";
return `${scheme}://${relay.publicEndpoint}`;
}
export type StatusResult = ListResult<StatusRow>;
@@ -350,7 +386,7 @@ export async function runStatusCommand(
): Promise<StatusResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const daemonTarget = state.listen.trim();
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode = await resolveDaemonNodeLabel(state);
@@ -359,6 +395,7 @@ export async function runStatusCommand(
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let daemonVersion: string | null = null;
let daemonProviders: ProviderBinaryStatus[] | undefined;
let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) });
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
@@ -366,20 +403,26 @@ export async function runStatusCommand(
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
}
if (host) {
const probe = await probeDaemonOverWebsocket({ host, state });
({ connectedDaemon, localDaemon, daemonNode, daemonVersion, daemonProviders, note } =
applyProbeToStatus({
probe,
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
note,
}));
} else {
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
if (daemonTarget) {
const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state });
({
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
relayStatus,
note,
} = applyProbeToStatus({
probe,
connectedDaemon,
localDaemon,
daemonNode,
daemonVersion,
daemonProviders,
relayStatus,
note,
}));
}
const cliVersion = resolveCliVersion();
@@ -398,7 +441,7 @@ export async function runStatusCommand(
connectedDaemon,
home: state.home,
listen: state.listen,
relay: formatRelayStatus(state),
relay: relayStatus,
hostname: state.pidInfo?.hostname ?? null,
pid: state.pidInfo?.pid ?? null,
startedAt: state.pidInfo?.startedAt ?? null,
+24 -53
View File
@@ -2,13 +2,7 @@ import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@cl
import { Command, Option } from "commander";
import { writeFileSync } from "node:fs";
import path from "node:path";
import {
generateLocalPairingOffer,
loadConfig,
loadPersistedConfig,
type CliConfigOverrides,
type PersistedConfig,
} from "@getpaseo/server";
import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server";
import {
resolveLocalPaseoHome,
resolveLocalDaemonState,
@@ -19,6 +13,11 @@ import {
} from "./daemon/local-daemon.js";
import { tryConnectToDaemon } from "../utils/client.js";
import { formatPairingInstructions } from "../output/pairing.js";
import {
confirmRelayPairing,
printDirectConnectionGuidance,
resolveLocalPairingOffer,
} from "./daemon/pair.js";
interface OnboardOptions extends DaemonStartOptions {
timeout?: string;
@@ -70,37 +69,6 @@ function parseTimeoutMs(raw: string | undefined): number {
return Math.ceil(seconds * 1000);
}
function toCliOverrides(options: OnboardOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {};
if (options.listen) {
cliOverrides.listen = options.listen;
} else if (options.port) {
cliOverrides.listen = `127.0.0.1:${options.port}`;
}
if (options.relay === false) {
cliOverrides.relayEnabled = false;
}
if (options.hostnames) {
const raw = options.hostnames.trim();
cliOverrides.hostnames =
raw.toLowerCase() === "true"
? true
: raw
.split(",")
.map((host) => host.trim())
.filter(Boolean);
}
if (options.mcp === false) {
cliOverrides.mcpEnabled = false;
}
return cliOverrides;
}
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
const configPath = path.join(paseoHome, "config.json");
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
@@ -338,6 +306,7 @@ export function onboardCommand(): Command {
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--relay", "Enable relay connection without prompting")
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option(
@@ -477,8 +446,6 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
}
const voiceEnabled = await resolveAndPersistVoice(paseoHome, options);
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) });
log.message(
voiceEnabled
? "Voice features enabled. Local speech models will be downloaded automatically if missing."
@@ -492,26 +459,30 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
richUi,
});
if (config.relayEnabled === false) {
log.warn("Relay is disabled; pairing offer is unavailable for this daemon.");
if (options.relay === false) {
log.message("Relay pairing skipped because --no-relay was provided.");
printNextSteps(null, paseoHome, richUi);
if (richUi) {
outro("Paseo daemon is running.");
}
if (richUi) outro("Paseo daemon is running.");
return;
}
const pairing = await generateLocalPairingOffer({
let pairing = await resolveLocalPairingOffer({
paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
relayPublicUseTls: config.relayPublicUseTls,
appBaseUrl: config.appBaseUrl,
includeQr: true,
enableRelay: options.relay === true,
});
if (!pairing.relayEnabled) {
const shouldEnable = richUi ? await confirmRelayPairing() : false;
if (!shouldEnable) {
printDirectConnectionGuidance();
printNextSteps(null, paseoHome, richUi);
if (richUi) outro("Paseo daemon is running.");
return;
}
pairing = await resolveLocalPairingOffer({ paseoHome, enableRelay: true });
log.success("Relay enabled");
}
if (!pairing.url) {
log.warn("Relay pairing URL is unavailable for this daemon configuration.");
printNextSteps(null, paseoHome, richUi);
+17
View File
@@ -15,6 +15,23 @@ describe("runCli", () => {
).toEqual(["node", "paseo", "onboard"]);
});
it("routes explicit root relay flags to onboard", () => {
expect(
createCliParseArgv({
argv: ["--relay"],
cwd: process.cwd(),
nodeArgv: ["node", "paseo"],
}),
).toEqual(["node", "paseo", "onboard", "--relay"]);
expect(
createCliParseArgv({
argv: ["--no-relay"],
cwd: process.cwd(),
nodeArgv: ["node", "paseo"],
}),
).toEqual(["node", "paseo", "onboard", "--no-relay"]);
});
it("preserves known CLI command argv", () => {
expect(
createCliParseArgv({
+7 -1
View File
@@ -25,7 +25,13 @@ export function createCliParseArgv(input: {
}
const nodeArgv = input.nodeArgv ?? ["paseo", "paseo"];
const cliArgv = invocation.argv.length === 0 ? ["onboard"] : invocation.argv;
const isOnboardRootFlag = invocation.argv[0] === "--relay" || invocation.argv[0] === "--no-relay";
let cliArgv = invocation.argv;
if (invocation.argv.length === 0) {
cliArgv = ["onboard"];
} else if (isOnboardRootFlag) {
cliArgv = ["onboard", ...invocation.argv];
}
return [...nodeArgv, ...cliArgv];
}
+4 -1
View File
@@ -200,7 +200,10 @@ function stripIpcPrefix(trimmed: string): string {
}
export function resolveDaemonTarget(host: string): DaemonTarget {
const trimmed = host.trim();
const trimmed = normalizeDaemonHost(host);
if (!trimmed) {
throw new Error(`Invalid daemon target: ${host}`);
}
if (
trimmed.startsWith("unix://") ||
trimmed.startsWith("pipe://") ||
+105 -16
View File
@@ -7,15 +7,16 @@
*
* Tests:
* - daemon --help shows subcommands
* - daemon pair prints a local pairing link without requiring a running daemon
* - daemon pair does not create a relay offer without explicit consent
* - daemon status reports stopped when daemon not running
* - daemon status --json outputs valid JSON
* - daemon stop handles daemon not running gracefully
* - daemon restart starts the daemon and can be cleaned up
* - daemon status probes the live relay state over local IPC
*/
import assert from "node:assert";
import { mkdtemp, rm } from "fs/promises";
import { mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { runLocalPaseo } from "./helpers/local-cli.ts";
@@ -44,14 +45,17 @@ try {
console.log("✓ daemon --help shows subcommands\n");
}
// Test 2: daemon pair works without daemon process
// Test 2: non-interactive pairing keeps relay disabled by default
{
console.log("Test 2: daemon pair prints local pairing URL");
console.log("Test 2: daemon pair requires explicit relay consent");
const result = await daemonCommand(["pair"]);
assert.strictEqual(result.exitCode, 0, "daemon pair should succeed");
assert(result.stdout.includes("Scan to pair:"), "output should include scan header");
assert(result.stdout.includes("#offer="), "output should include pairing offer fragment");
console.log("✓ daemon pair prints local pairing URL\n");
assert.strictEqual(result.exitCode, 1, "daemon pair should require relay consent");
assert(
result.stderr.includes("Relay pairing is disabled"),
"output should explain that relay pairing is disabled",
);
assert(!result.stdout.includes("#offer="), "output should not include a pairing offer");
console.log("✓ daemon pair requires explicit relay consent\n");
}
// Test 3: daemon status reports stopped when daemon not running
@@ -65,16 +69,17 @@ try {
console.log("✓ daemon status reports stopped when not running\n");
}
// Test 4: daemon pair --json outputs valid JSON
// Test 4: daemon pair --json exposes the machine-readable disabled state
{
console.log("Test 4: daemon pair --json outputs JSON");
console.log("Test 4: daemon pair --json reports relay disabled");
const result = await daemonCommand(["pair", "--json"]);
assert.strictEqual(result.exitCode, 0, "daemon pair --json should succeed");
const pairing = JSON.parse(result.stdout);
assert.strictEqual(pairing.relayEnabled, true, "pairing should report relay enabled");
assert.match(pairing.url, /#offer=/, "pairing URL should include offer fragment");
assert.strictEqual(typeof pairing.qr, "string", "pairing should include QR content");
console.log("✓ daemon pair --json outputs valid JSON\n");
assert.strictEqual(result.exitCode, 1, "daemon pair --json should require relay consent");
const errorLine = result.stderr.split("\n").find((line) => line.startsWith("{"));
assert(errorLine, "stderr should include a structured error");
const error = JSON.parse(errorLine);
assert.strictEqual(error.code, "RELAY_DISABLED", "error should identify relay state");
assert(!result.stdout.includes("#offer="), "output should not include a pairing offer");
console.log("✓ daemon pair --json reports relay disabled\n");
}
// Test 5: daemon status --json outputs valid JSON
@@ -119,6 +124,90 @@ try {
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart");
console.log("✓ daemon restart starts and stop cleanup succeeds\n");
}
// Test 8: status uses the running daemon as relay authority over local IPC
{
console.log("Test 8: daemon status probes live relay state over local IPC");
const listen =
process.platform === "win32"
? `\\\\.\\pipe\\paseo-status-${process.pid}-${Date.now()}`
: join(paseoHome, "status.sock");
const start = await daemonCommand(["start", "--listen", listen, "--relay"]);
assert.strictEqual(start.exitCode, 0, `IPC daemon should start: ${start.stderr}`);
const configPath = join(paseoHome, "config.json");
const config = JSON.parse(await readFile(configPath, "utf-8"));
config.daemon = { ...config.daemon, listen };
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
const pidPath = join(paseoHome, "paseo.pid");
const pidContents = await readFile(pidPath, "utf-8");
await unlink(pidPath);
const status = await daemonCommand(["status", "--json"]);
const pairing = await daemonCommand(["pair", "--json"]);
await writeFile(pidPath, pidContents, "utf-8");
assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`);
const payload = JSON.parse(status.stdout);
assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable");
assert.strictEqual(payload.localDaemon, "stopped", "missing PID should report stopped locally");
assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state");
assert.strictEqual(pairing.exitCode, 0, `IPC daemon pairing should succeed: ${pairing.stderr}`);
const pairingPayload = JSON.parse(pairing.stdout);
assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state");
assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer");
const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-"));
try {
await writeFile(
join(foreignHome, "config.json"),
`${JSON.stringify({ daemon: { listen } }, null, 2)}\n`,
"utf-8",
);
const foreignPairing = await runLocalPaseo(
["daemon", "pair", "--home", foreignHome, "--json"],
{ PASEO_HOME: foreignHome },
);
assert.notStrictEqual(
foreignPairing.exitCode,
0,
"pairing should reject a daemon owned by another home",
);
assert(
foreignPairing.stderr.includes("different Paseo home"),
"pairing should explain the daemon identity mismatch",
);
assert(!foreignPairing.stdout.includes("#offer="), "pairing should not expose another offer");
} finally {
await rm(foreignHome, { recursive: true, force: true });
}
const cleanup = await daemonCommand(["stop", "--force"]);
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after IPC status");
console.log("✓ daemon status probes live relay state over local IPC\n");
}
// Test 9: --relay accepts an already-enabled persisted relay while stopped
{
console.log("Test 9: daemon pair --relay accepts persisted relay while stopped");
const configPath = join(paseoHome, "config.json");
const config = JSON.parse(await readFile(configPath, "utf-8"));
config.daemon = {
...config.daemon,
relay: { ...config.daemon?.relay, enabled: true },
};
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
const pairing = await daemonCommand(["pair", "--relay", "--json"]);
assert.strictEqual(
pairing.exitCode,
0,
`persisted relay pairing should succeed while stopped: ${pairing.stderr}`,
);
const payload = JSON.parse(pairing.stdout);
assert.strictEqual(payload.relayEnabled, true, "pairing should preserve persisted relay state");
assert.match(payload.url, /#offer=/, "pairing should include the offline offer");
console.log("✓ daemon pair --relay accepts persisted relay while stopped\n");
}
} finally {
// Best-effort daemon cleanup in case assertions fail before explicit stop.
await daemonCommand(["stop", "--force"]);
+26 -7
View File
@@ -15,7 +15,7 @@ const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-"));
const port = await getAvailablePort();
try {
console.log("Test 1: `paseo` runs blocking onboarding and prints pairing info");
console.log("Test 1: `paseo` runs blocking onboarding without implicit relay pairing");
const onboard =
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow();
@@ -24,12 +24,12 @@ try {
0,
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`,
);
assert(onboard.stdout.includes("Scan to pair"), "onboard output should include scan header");
assert(!onboard.stdout.includes("Scan to pair"), "onboard output should not include scan header");
assert(!onboard.stdout.includes("#offer="), "onboard output should not include a pairing offer");
assert(
onboard.stdout.includes("Pairing link"),
"onboard output should include pairing link header",
onboard.stdout.includes("Daemon is running with relay off"),
"onboard output should explain the direct connection path",
);
assert(onboard.stdout.includes("#offer="), "onboard output should include pairing offer URL");
assert(
onboard.stdout.includes("CLI quick reference"),
"onboard output should include CLI quick reference",
@@ -50,9 +50,28 @@ try {
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow();
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`);
assert(status.stdout.includes("running"), "daemon should be running when onboarding exits");
console.log("✓ onboarding prints pairing info and waits for daemon readiness\n");
console.log("✓ onboarding keeps relay disabled and waits for daemon readiness\n");
console.log("Test 2: non-interactive onboarding persists voice disabled config");
console.log("Test 2: --no-relay suppresses pairing for an already-running daemon");
const enableRelay =
await $`PASEO_HOME=${paseoHome} npx paseo daemon pair --home ${paseoHome} --relay`.nothrow();
assert.strictEqual(enableRelay.exitCode, 0, `relay enable should succeed: ${enableRelay.stderr}`);
assert(enableRelay.stdout.includes("#offer="), "relay enable should produce a pairing offer");
const noRelayOnboard =
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} npx paseo --no-relay`.nothrow();
assert.strictEqual(
noRelayOnboard.exitCode,
0,
`--no-relay onboarding should succeed: ${noRelayOnboard.stderr}`,
);
assert(
!noRelayOnboard.stdout.includes("#offer="),
"--no-relay onboarding should not include a pairing offer",
);
console.log("✓ --no-relay suppresses pairing for an already-running daemon\n");
console.log("Test 3: non-interactive onboarding persists voice disabled config");
const configRaw = await readFile(join(paseoHome, "config.json"), "utf-8");
const config = JSON.parse(configRaw) as {
features?: {
@@ -26,6 +26,17 @@ console.log("=== CLI IPC Target Helpers ===\n");
console.log("✓ unix hosts resolve to ws+unix URLs\n");
}
{
console.log("Test 1b: bare unix socket paths resolve at the connection boundary");
const target = resolveDaemonTarget("/tmp/paseo.sock");
assert.deepStrictEqual(target, {
type: "ipc",
url: "ws+unix:///tmp/paseo.sock:/ws",
socketPath: "/tmp/paseo.sock",
});
console.log("✓ bare unix socket paths resolve at the connection boundary\n");
}
{
console.log("Test 2: pipe hosts preserve the Node socketPath transport form");
const target = resolveDaemonTarget("pipe://\\\\.\\pipe\\paseo-managed-test");