* 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.
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
import { once } from "node:events";
|
|
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import net from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
|
|
|
export interface IsolatedHostDaemon {
|
|
serverId: string;
|
|
port: number;
|
|
paseoHome: string;
|
|
getPid(): number | undefined;
|
|
restart(): Promise<void>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
interface IsolatedHostDaemonOptions {
|
|
mutableRelay?: {
|
|
enabled: boolean;
|
|
endpoint?: string;
|
|
};
|
|
}
|
|
|
|
async function getAvailablePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close(() => reject(new Error("Failed to acquire an isolated daemon port")));
|
|
return;
|
|
}
|
|
server.close(() => resolve(address.port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
|
const deadline = Date.now() + 20_000;
|
|
let lastError: unknown = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
|
|
}
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const socket = net.connect(port, "127.0.0.1", () => {
|
|
socket.end();
|
|
resolve();
|
|
});
|
|
socket.setTimeout(1_000, () => {
|
|
socket.destroy();
|
|
reject(new Error(`Connection timed out to isolated daemon port ${port}`));
|
|
});
|
|
socket.on("error", reject);
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Isolated host daemon did not listen on ${port}: ${
|
|
lastError instanceof Error ? lastError.message : String(lastError)
|
|
}`,
|
|
);
|
|
}
|
|
|
|
async function stopProcess(child: ChildProcess): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
child.kill("SIGTERM");
|
|
const timeout = setTimeout(() => {
|
|
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
}, 5_000);
|
|
try {
|
|
await once(child, "exit");
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
export async function startIsolatedHostDaemon(
|
|
serverId: string,
|
|
options: IsolatedHostDaemonOptions = {},
|
|
): Promise<IsolatedHostDaemon> {
|
|
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
|
let port = await getAvailablePort();
|
|
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
|
|
|
|
const metroPort = process.env.E2E_METRO_PORT;
|
|
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
|
|
|
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
|
|
if (options.mutableRelay) {
|
|
const endpoint =
|
|
options.mutableRelay.endpoint ??
|
|
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
|
await writeFile(
|
|
path.join(paseoHome, "config.json"),
|
|
`${JSON.stringify({
|
|
version: 1,
|
|
daemon: {
|
|
relay: {
|
|
enabled: options.mutableRelay.enabled,
|
|
endpoint,
|
|
publicEndpoint: endpoint,
|
|
useTls: false,
|
|
publicUseTls: false,
|
|
},
|
|
},
|
|
})}\n`,
|
|
);
|
|
}
|
|
const serverDir = path.resolve(__dirname, "../../../server");
|
|
const tsxBin = execSync("which tsx").toString().trim();
|
|
const spawnDaemon = async (): Promise<ChildProcess> => {
|
|
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
|
cwd: serverDir,
|
|
env: withDisabledE2ESpeechEnv({
|
|
...process.env,
|
|
PASEO_HOME: paseoHome,
|
|
PASEO_SERVER_ID: serverId,
|
|
PASEO_LISTEN: `127.0.0.1:${port}`,
|
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
|
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
|
|
PASEO_NODE_ENV: "development",
|
|
NODE_ENV: "development",
|
|
}),
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
detached: false,
|
|
});
|
|
|
|
let stderr = "";
|
|
child.stderr?.on("data", (chunk: Buffer) => {
|
|
stderr += chunk.toString("utf8");
|
|
stderr = stderr.split("\n").slice(-40).join("\n");
|
|
});
|
|
|
|
try {
|
|
await waitForServer(port, child);
|
|
return child;
|
|
} catch (error) {
|
|
await stopProcess(child);
|
|
throw new Error(
|
|
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
};
|
|
|
|
let child: ChildProcess;
|
|
try {
|
|
child = await spawnDaemon();
|
|
} catch (error) {
|
|
await rm(paseoHome, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
let closed = false;
|
|
|
|
return {
|
|
serverId,
|
|
port,
|
|
paseoHome,
|
|
getPid: () => child.pid,
|
|
restart: async () => {
|
|
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
|
await stopProcess(child);
|
|
child = await spawnDaemon();
|
|
},
|
|
close: async () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
await stopProcess(child);
|
|
await rm(paseoHome, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|