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:
@@ -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://") ||
|
||||
|
||||
Reference in New Issue
Block a user