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:
@@ -7,15 +7,16 @@
|
||||
*
|
||||
* Tests:
|
||||
* - daemon --help shows subcommands
|
||||
* - daemon pair prints a local pairing link without requiring a running daemon
|
||||
* - daemon pair does not create a relay offer without explicit consent
|
||||
* - daemon status reports stopped when daemon not running
|
||||
* - daemon status --json outputs valid JSON
|
||||
* - daemon stop handles daemon not running gracefully
|
||||
* - daemon restart starts the daemon and can be cleaned up
|
||||
* - daemon status probes the live relay state over local IPC
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
@@ -44,14 +45,17 @@ try {
|
||||
console.log("✓ daemon --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: daemon pair works without daemon process
|
||||
// Test 2: non-interactive pairing keeps relay disabled by default
|
||||
{
|
||||
console.log("Test 2: daemon pair prints local pairing URL");
|
||||
console.log("Test 2: daemon pair requires explicit relay consent");
|
||||
const result = await daemonCommand(["pair"]);
|
||||
assert.strictEqual(result.exitCode, 0, "daemon pair should succeed");
|
||||
assert(result.stdout.includes("Scan to pair:"), "output should include scan header");
|
||||
assert(result.stdout.includes("#offer="), "output should include pairing offer fragment");
|
||||
console.log("✓ daemon pair prints local pairing URL\n");
|
||||
assert.strictEqual(result.exitCode, 1, "daemon pair should require relay consent");
|
||||
assert(
|
||||
result.stderr.includes("Relay pairing is disabled"),
|
||||
"output should explain that relay pairing is disabled",
|
||||
);
|
||||
assert(!result.stdout.includes("#offer="), "output should not include a pairing offer");
|
||||
console.log("✓ daemon pair requires explicit relay consent\n");
|
||||
}
|
||||
|
||||
// Test 3: daemon status reports stopped when daemon not running
|
||||
@@ -65,16 +69,17 @@ try {
|
||||
console.log("✓ daemon status reports stopped when not running\n");
|
||||
}
|
||||
|
||||
// Test 4: daemon pair --json outputs valid JSON
|
||||
// Test 4: daemon pair --json exposes the machine-readable disabled state
|
||||
{
|
||||
console.log("Test 4: daemon pair --json outputs JSON");
|
||||
console.log("Test 4: daemon pair --json reports relay disabled");
|
||||
const result = await daemonCommand(["pair", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "daemon pair --json should succeed");
|
||||
const pairing = JSON.parse(result.stdout);
|
||||
assert.strictEqual(pairing.relayEnabled, true, "pairing should report relay enabled");
|
||||
assert.match(pairing.url, /#offer=/, "pairing URL should include offer fragment");
|
||||
assert.strictEqual(typeof pairing.qr, "string", "pairing should include QR content");
|
||||
console.log("✓ daemon pair --json outputs valid JSON\n");
|
||||
assert.strictEqual(result.exitCode, 1, "daemon pair --json should require relay consent");
|
||||
const errorLine = result.stderr.split("\n").find((line) => line.startsWith("{"));
|
||||
assert(errorLine, "stderr should include a structured error");
|
||||
const error = JSON.parse(errorLine);
|
||||
assert.strictEqual(error.code, "RELAY_DISABLED", "error should identify relay state");
|
||||
assert(!result.stdout.includes("#offer="), "output should not include a pairing offer");
|
||||
console.log("✓ daemon pair --json reports relay disabled\n");
|
||||
}
|
||||
|
||||
// Test 5: daemon status --json outputs valid JSON
|
||||
@@ -119,6 +124,90 @@ try {
|
||||
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart");
|
||||
console.log("✓ daemon restart starts and stop cleanup succeeds\n");
|
||||
}
|
||||
|
||||
// Test 8: status uses the running daemon as relay authority over local IPC
|
||||
{
|
||||
console.log("Test 8: daemon status probes live relay state over local IPC");
|
||||
const listen =
|
||||
process.platform === "win32"
|
||||
? `\\\\.\\pipe\\paseo-status-${process.pid}-${Date.now()}`
|
||||
: join(paseoHome, "status.sock");
|
||||
const start = await daemonCommand(["start", "--listen", listen, "--relay"]);
|
||||
assert.strictEqual(start.exitCode, 0, `IPC daemon should start: ${start.stderr}`);
|
||||
|
||||
const configPath = join(paseoHome, "config.json");
|
||||
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
||||
config.daemon = { ...config.daemon, listen };
|
||||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
||||
const pidPath = join(paseoHome, "paseo.pid");
|
||||
const pidContents = await readFile(pidPath, "utf-8");
|
||||
await unlink(pidPath);
|
||||
const status = await daemonCommand(["status", "--json"]);
|
||||
const pairing = await daemonCommand(["pair", "--json"]);
|
||||
await writeFile(pidPath, pidContents, "utf-8");
|
||||
assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`);
|
||||
const payload = JSON.parse(status.stdout);
|
||||
assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable");
|
||||
assert.strictEqual(payload.localDaemon, "stopped", "missing PID should report stopped locally");
|
||||
assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state");
|
||||
assert.strictEqual(pairing.exitCode, 0, `IPC daemon pairing should succeed: ${pairing.stderr}`);
|
||||
const pairingPayload = JSON.parse(pairing.stdout);
|
||||
assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state");
|
||||
assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer");
|
||||
|
||||
const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-"));
|
||||
try {
|
||||
await writeFile(
|
||||
join(foreignHome, "config.json"),
|
||||
`${JSON.stringify({ daemon: { listen } }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const foreignPairing = await runLocalPaseo(
|
||||
["daemon", "pair", "--home", foreignHome, "--json"],
|
||||
{ PASEO_HOME: foreignHome },
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
foreignPairing.exitCode,
|
||||
0,
|
||||
"pairing should reject a daemon owned by another home",
|
||||
);
|
||||
assert(
|
||||
foreignPairing.stderr.includes("different Paseo home"),
|
||||
"pairing should explain the daemon identity mismatch",
|
||||
);
|
||||
assert(!foreignPairing.stdout.includes("#offer="), "pairing should not expose another offer");
|
||||
} finally {
|
||||
await rm(foreignHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const cleanup = await daemonCommand(["stop", "--force"]);
|
||||
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after IPC status");
|
||||
console.log("✓ daemon status probes live relay state over local IPC\n");
|
||||
}
|
||||
|
||||
// Test 9: --relay accepts an already-enabled persisted relay while stopped
|
||||
{
|
||||
console.log("Test 9: daemon pair --relay accepts persisted relay while stopped");
|
||||
const configPath = join(paseoHome, "config.json");
|
||||
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
||||
config.daemon = {
|
||||
...config.daemon,
|
||||
relay: { ...config.daemon?.relay, enabled: true },
|
||||
};
|
||||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
||||
|
||||
const pairing = await daemonCommand(["pair", "--relay", "--json"]);
|
||||
|
||||
assert.strictEqual(
|
||||
pairing.exitCode,
|
||||
0,
|
||||
`persisted relay pairing should succeed while stopped: ${pairing.stderr}`,
|
||||
);
|
||||
const payload = JSON.parse(pairing.stdout);
|
||||
assert.strictEqual(payload.relayEnabled, true, "pairing should preserve persisted relay state");
|
||||
assert.match(payload.url, /#offer=/, "pairing should include the offline offer");
|
||||
console.log("✓ daemon pair --relay accepts persisted relay while stopped\n");
|
||||
}
|
||||
} finally {
|
||||
// Best-effort daemon cleanup in case assertions fail before explicit stop.
|
||||
await daemonCommand(["stop", "--force"]);
|
||||
|
||||
@@ -15,7 +15,7 @@ const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-"));
|
||||
const port = await getAvailablePort();
|
||||
|
||||
try {
|
||||
console.log("Test 1: `paseo` runs blocking onboarding and prints pairing info");
|
||||
console.log("Test 1: `paseo` runs blocking onboarding without implicit relay pairing");
|
||||
const onboard =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow();
|
||||
|
||||
@@ -24,12 +24,12 @@ try {
|
||||
0,
|
||||
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`,
|
||||
);
|
||||
assert(onboard.stdout.includes("Scan to pair"), "onboard output should include scan header");
|
||||
assert(!onboard.stdout.includes("Scan to pair"), "onboard output should not include scan header");
|
||||
assert(!onboard.stdout.includes("#offer="), "onboard output should not include a pairing offer");
|
||||
assert(
|
||||
onboard.stdout.includes("Pairing link"),
|
||||
"onboard output should include pairing link header",
|
||||
onboard.stdout.includes("Daemon is running with relay off"),
|
||||
"onboard output should explain the direct connection path",
|
||||
);
|
||||
assert(onboard.stdout.includes("#offer="), "onboard output should include pairing offer URL");
|
||||
assert(
|
||||
onboard.stdout.includes("CLI quick reference"),
|
||||
"onboard output should include CLI quick reference",
|
||||
@@ -50,9 +50,28 @@ try {
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow();
|
||||
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`);
|
||||
assert(status.stdout.includes("running"), "daemon should be running when onboarding exits");
|
||||
console.log("✓ onboarding prints pairing info and waits for daemon readiness\n");
|
||||
console.log("✓ onboarding keeps relay disabled and waits for daemon readiness\n");
|
||||
|
||||
console.log("Test 2: non-interactive onboarding persists voice disabled config");
|
||||
console.log("Test 2: --no-relay suppresses pairing for an already-running daemon");
|
||||
const enableRelay =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo daemon pair --home ${paseoHome} --relay`.nothrow();
|
||||
assert.strictEqual(enableRelay.exitCode, 0, `relay enable should succeed: ${enableRelay.stderr}`);
|
||||
assert(enableRelay.stdout.includes("#offer="), "relay enable should produce a pairing offer");
|
||||
|
||||
const noRelayOnboard =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} npx paseo --no-relay`.nothrow();
|
||||
assert.strictEqual(
|
||||
noRelayOnboard.exitCode,
|
||||
0,
|
||||
`--no-relay onboarding should succeed: ${noRelayOnboard.stderr}`,
|
||||
);
|
||||
assert(
|
||||
!noRelayOnboard.stdout.includes("#offer="),
|
||||
"--no-relay onboarding should not include a pairing offer",
|
||||
);
|
||||
console.log("✓ --no-relay suppresses pairing for an already-running daemon\n");
|
||||
|
||||
console.log("Test 3: non-interactive onboarding persists voice disabled config");
|
||||
const configRaw = await readFile(join(paseoHome, "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw) as {
|
||||
features?: {
|
||||
|
||||
@@ -26,6 +26,17 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
console.log("✓ unix hosts resolve to ws+unix URLs\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 1b: bare unix socket paths resolve at the connection boundary");
|
||||
const target = resolveDaemonTarget("/tmp/paseo.sock");
|
||||
assert.deepStrictEqual(target, {
|
||||
type: "ipc",
|
||||
url: "ws+unix:///tmp/paseo.sock:/ws",
|
||||
socketPath: "/tmp/paseo.sock",
|
||||
});
|
||||
console.log("✓ bare unix socket paths resolve at the connection boundary\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: pipe hosts preserve the Node socketPath transport form");
|
||||
const target = resolveDaemonTarget("pipe://\\\\.\\pipe\\paseo-managed-test");
|
||||
|
||||
Reference in New Issue
Block a user