feat(cli): connect via relay using pairing offer URL (#639)
Pass `--host https://app.paseo.sh/#offer=...` (or `PASEO_HOST=...`) to any CLI command and the connection routes through the relay with E2EE, the same way the mobile and desktop apps connect. Same `paseo daemon pair` link the app already accepts — no new pairing flow. Closes #181
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import { Buffer } from "buffer";
|
|
||||||
import {
|
import {
|
||||||
buildDaemonWebSocketUrl,
|
buildDaemonWebSocketUrl,
|
||||||
buildRelayWebSocketUrl as buildSharedRelayWebSocketUrl,
|
buildRelayWebSocketUrl as buildSharedRelayWebSocketUrl,
|
||||||
@@ -9,6 +8,8 @@ import {
|
|||||||
type HostPortParts,
|
type HostPortParts,
|
||||||
} from "@server/shared/daemon-endpoints";
|
} from "@server/shared/daemon-endpoints";
|
||||||
|
|
||||||
|
export { decodeOfferFragmentPayload } from "@server/shared/connection-offer";
|
||||||
|
|
||||||
export type { HostPortParts };
|
export type { HostPortParts };
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -19,17 +20,6 @@ export {
|
|||||||
parseHostPort,
|
parseHostPort,
|
||||||
};
|
};
|
||||||
|
|
||||||
function decodeBase64UrlToUtf8(input: string): string {
|
|
||||||
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
|
||||||
return Buffer.from(padded, "base64").toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeOfferFragmentPayload(encoded: string): unknown {
|
|
||||||
const json = decodeBase64UrlToUtf8(encoded);
|
|
||||||
return JSON.parse(json) as unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
|
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
|
||||||
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
|
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { loadConfig, resolvePaseoHome, DaemonClient } from "@getpaseo/server";
|
import {
|
||||||
|
loadConfig,
|
||||||
|
resolvePaseoHome,
|
||||||
|
DaemonClient,
|
||||||
|
buildRelayWebSocketUrl,
|
||||||
|
parseConnectionOfferFromUrl,
|
||||||
|
type ConnectionOffer,
|
||||||
|
type WebSocketLike,
|
||||||
|
} from "@getpaseo/server";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { getOrCreateCliClientId } from "./client-id.js";
|
import { getOrCreateCliClientId } from "./client-id.js";
|
||||||
@@ -176,18 +184,14 @@ export function resolveDaemonTarget(host: string): DaemonTarget {
|
|||||||
* Create a WebSocket factory that works in Node.js
|
* Create a WebSocket factory that works in Node.js
|
||||||
*/
|
*/
|
||||||
function createNodeWebSocketFactory() {
|
function createNodeWebSocketFactory() {
|
||||||
return (url: string, options?: { headers?: Record<string, string>; socketPath?: string }) => {
|
return (
|
||||||
|
url: string,
|
||||||
|
options?: { headers?: Record<string, string>; socketPath?: string },
|
||||||
|
): WebSocketLike => {
|
||||||
return new WebSocket(url, {
|
return new WebSocket(url, {
|
||||||
headers: options?.headers,
|
headers: options?.headers,
|
||||||
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
|
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
|
||||||
}) as unknown as {
|
}) as unknown as WebSocketLike;
|
||||||
readyState: number;
|
|
||||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
|
||||||
close: (code?: number, reason?: string) => void;
|
|
||||||
binaryType?: string;
|
|
||||||
on: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
||||||
off: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +218,7 @@ async function tryConnectHost(
|
|||||||
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
|
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
|
||||||
}),
|
}),
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
} as unknown as ConstructorParameters<typeof DaemonClient>[0]);
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -225,12 +229,64 @@ async function tryConnectHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function connectViaRelayOffer(
|
||||||
|
offer: ConnectionOffer,
|
||||||
|
clientId: string,
|
||||||
|
timeout: number,
|
||||||
|
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
||||||
|
): Promise<DaemonClient> {
|
||||||
|
const url = buildRelayWebSocketUrl({
|
||||||
|
endpoint: offer.relay.endpoint,
|
||||||
|
serverId: offer.serverId,
|
||||||
|
role: "client",
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url,
|
||||||
|
clientId,
|
||||||
|
clientType: "cli",
|
||||||
|
appVersion: resolveCliVersion(),
|
||||||
|
connectTimeoutMs: timeout,
|
||||||
|
webSocketFactory: (target: string, config?: { headers?: Record<string, string> }) =>
|
||||||
|
nodeWebSocketFactory(target, { headers: config?.headers }),
|
||||||
|
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
await client.close().catch(() => {});
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const lastError = client.lastError ? ` (${client.lastError})` : "";
|
||||||
|
throw new Error(`Failed to connect via relay offer: ${message}${lastError}`, { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null {
|
||||||
|
if (!host) return null;
|
||||||
|
try {
|
||||||
|
return parseConnectionOfferFromUrl(host);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Invalid pairing offer URL: ${message}`, { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||||
const clientId = await getOrCreateCliClientId();
|
const clientId = await getOrCreateCliClientId();
|
||||||
const hosts = resolveDaemonHostCandidates(options);
|
|
||||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
||||||
|
|
||||||
|
const explicitHost = options?.host ?? process.env.PASEO_HOST;
|
||||||
|
const offer = parseHostOfferOrNull(explicitHost);
|
||||||
|
if (offer) {
|
||||||
|
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = resolveDaemonHostCandidates(options);
|
||||||
|
|
||||||
async function tryNext(index: number, lastError: unknown): Promise<DaemonClient> {
|
async function tryNext(index: number, lastError: unknown): Promise<DaemonClient> {
|
||||||
if (index >= hosts.length) {
|
if (index >= hosts.length) {
|
||||||
if (lastError instanceof Error) throw lastError;
|
if (lastError instanceof Error) throw lastError;
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import net from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
buildRelayWebSocketUrl,
|
||||||
|
DaemonClient,
|
||||||
|
generateLocalPairingOffer,
|
||||||
|
parseConnectionOfferFromUrl,
|
||||||
|
} from "@getpaseo/server";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
import { getAvailablePort } from "../helpers/network.ts";
|
||||||
|
import { createE2ETestContext } from "../helpers/test-daemon.ts";
|
||||||
|
|
||||||
|
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
|
||||||
|
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
|
||||||
|
const wranglerCliPath = createRequire(import.meta.url).resolve("wrangler/bin/wrangler.js");
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const relayDir = path.resolve(__dirname, "../../../relay");
|
||||||
|
const STARTUP_HOOK_TIMEOUT_MS = 120_000;
|
||||||
|
const SHUTDOWN_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitRelayLines(chunk: Buffer, prefix: "stdout" | "stderr"): void {
|
||||||
|
const lines = chunk.toString().split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim().length === 0) continue;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
if (prefix === "stderr") console.error(`[relay] ${line}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
else console.log(`[relay] ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardRelayStdout(data: Buffer): void {
|
||||||
|
emitRelayLines(data, "stdout");
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardRelayStderr(data: Buffer): void {
|
||||||
|
emitRelayLines(data, "stderr");
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnRelayDevServer(port: number): ChildProcess {
|
||||||
|
return spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
wranglerCliPath,
|
||||||
|
"dev",
|
||||||
|
"--local",
|
||||||
|
"--ip",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
String(port),
|
||||||
|
"--live-reload=false",
|
||||||
|
"--show-interactive-dev-session=false",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: relayDir,
|
||||||
|
env: { ...process.env },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRelayStillRunning(relayProcess: ChildProcess): void {
|
||||||
|
if (relayProcess.exitCode !== null) {
|
||||||
|
throw new Error(
|
||||||
|
`relay process exited before startup completed (code: ${relayProcess.exitCode})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryConnect(port: number): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = net.connect(port, "127.0.0.1", () => {
|
||||||
|
socket.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServer(
|
||||||
|
port: number,
|
||||||
|
relayProcess: ChildProcess,
|
||||||
|
timeout = 30000,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
assertRelayStillRunning(relayProcess);
|
||||||
|
try {
|
||||||
|
await tryConnect(port);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForProcessExit(relayProcess: ChildProcess, deadline: number): Promise<void> {
|
||||||
|
while (relayProcess.exitCode === null && Date.now() < deadline) {
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
|
||||||
|
if (relayProcess.exitCode !== null) return;
|
||||||
|
relayProcess.kill("SIGTERM");
|
||||||
|
await waitForProcessExit(relayProcess, Date.now() + SHUTDOWN_TIMEOUT_MS);
|
||||||
|
if (relayProcess.exitCode !== null) return;
|
||||||
|
relayProcess.kill("SIGKILL");
|
||||||
|
await waitForProcessExit(relayProcess, Date.now() + 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeRelayConnection(offerUrl: string): Promise<boolean> {
|
||||||
|
const offer = parseConnectionOfferFromUrl(offerUrl);
|
||||||
|
if (!offer) return false;
|
||||||
|
const url = buildRelayWebSocketUrl({
|
||||||
|
endpoint: offer.relay.endpoint,
|
||||||
|
serverId: offer.serverId,
|
||||||
|
role: "client",
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url,
|
||||||
|
clientId: `relay-probe-${Date.now()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
connectTimeoutMs: 4000,
|
||||||
|
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
webSocketFactory: (target: string, opts?: { headers?: Record<string, string> }) =>
|
||||||
|
new WebSocket(target, { headers: opts?.headers }) as unknown as ReturnType<
|
||||||
|
NonNullable<ConstructorParameters<typeof DaemonClient>[0]["webSocketFactory"]>
|
||||||
|
>,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDaemonRelayRegistered(offerUrl: string, timeoutMs = 30_000): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await probeRelayConnection(offerUrl)) return;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
throw new Error("Daemon failed to register with the relay within timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
(shouldRunRelayE2e ? describe : describe.skip)("CLI --host offer URL via relay", () => {
|
||||||
|
let relayPort: number;
|
||||||
|
let relayProcess: ChildProcess | null = null;
|
||||||
|
let ctx: Awaited<ReturnType<typeof createE2ETestContext>> | null = null;
|
||||||
|
let offerUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
relayPort = await getAvailablePort();
|
||||||
|
relayProcess = spawnRelayDevServer(relayPort);
|
||||||
|
relayProcess.stdout?.on("data", forwardRelayStdout);
|
||||||
|
relayProcess.stderr?.on("data", forwardRelayStderr);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForServer(relayPort, relayProcess, 60_000);
|
||||||
|
} catch (error) {
|
||||||
|
await stopRelayProcess(relayProcess);
|
||||||
|
relayProcess = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||||
|
ctx = await createE2ETestContext({
|
||||||
|
timeout: 60_000,
|
||||||
|
env: {
|
||||||
|
PASEO_RELAY_ENABLED: "true",
|
||||||
|
PASEO_RELAY_ENDPOINT: relayEndpoint,
|
||||||
|
PASEO_RELAY_PUBLIC_ENDPOINT: relayEndpoint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const offer = await generateLocalPairingOffer({
|
||||||
|
paseoHome: ctx.paseoHome,
|
||||||
|
relayEnabled: true,
|
||||||
|
relayEndpoint,
|
||||||
|
relayPublicEndpoint: relayEndpoint,
|
||||||
|
includeQr: false,
|
||||||
|
});
|
||||||
|
if (!offer.url) throw new Error("generateLocalPairingOffer returned no URL");
|
||||||
|
offerUrl = offer.url;
|
||||||
|
|
||||||
|
await waitForDaemonRelayRegistered(offerUrl, 30_000);
|
||||||
|
}, STARTUP_HOOK_TIMEOUT_MS);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (ctx) {
|
||||||
|
await ctx.stop();
|
||||||
|
ctx = null;
|
||||||
|
}
|
||||||
|
if (relayProcess) {
|
||||||
|
await stopRelayProcess(relayProcess);
|
||||||
|
relayProcess = null;
|
||||||
|
}
|
||||||
|
}, SHUTDOWN_TIMEOUT_MS);
|
||||||
|
|
||||||
|
it("runs `paseo --host <offer-url> ls` over the relay and matches direct ls output", async () => {
|
||||||
|
if (!ctx) throw new Error("test context not initialized");
|
||||||
|
|
||||||
|
const direct = await ctx.paseo(["ls", "--json"]);
|
||||||
|
expect(direct.exitCode, `direct ls failed: ${direct.stderr}`).toBe(0);
|
||||||
|
const directAgents = JSON.parse(direct.stdout.trim() || "[]");
|
||||||
|
expect(Array.isArray(directAgents)).toBe(true);
|
||||||
|
|
||||||
|
const relay = await ctx.paseo(["ls", "--json", "--host", offerUrl], {
|
||||||
|
timeout: 30_000,
|
||||||
|
env: { PASEO_HOST: offerUrl },
|
||||||
|
});
|
||||||
|
expect(relay.exitCode, `relay ls failed: ${relay.stderr}\nstdout: ${relay.stdout}`).toBe(0);
|
||||||
|
const relayAgents = JSON.parse(relay.stdout.trim() || "[]");
|
||||||
|
expect(Array.isArray(relayAgents)).toBe(true);
|
||||||
|
expect(relayAgents.length).toBe(directAgents.length);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -217,6 +217,7 @@ export async function startTestDaemon(options?: {
|
|||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
workDir?: string;
|
workDir?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<TestDaemonContext> {
|
}): Promise<TestDaemonContext> {
|
||||||
const port = options?.port ?? (await getAvailablePort());
|
const port = options?.port ?? (await getAvailablePort());
|
||||||
const { paseoHome, workDir } =
|
const { paseoHome, workDir } =
|
||||||
@@ -240,6 +241,7 @@ export async function startTestDaemon(options?: {
|
|||||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
||||||
// Force no TTY to prevent QR code output
|
// Force no TTY to prevent QR code output
|
||||||
CI: "true",
|
CI: "true",
|
||||||
|
...options?.env,
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
@@ -396,7 +398,10 @@ export async function runPaseoCli(
|
|||||||
*
|
*
|
||||||
* This is the main entry point for E2E tests.
|
* This is the main entry point for E2E tests.
|
||||||
*/
|
*/
|
||||||
export async function createE2ETestContext(options?: { timeout?: number }): Promise<
|
export async function createE2ETestContext(options?: {
|
||||||
|
timeout?: number;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<
|
||||||
TestDaemonContext & {
|
TestDaemonContext & {
|
||||||
/** Run a paseo CLI command against this daemon */
|
/** Run a paseo CLI command against this daemon */
|
||||||
paseo: (
|
paseo: (
|
||||||
@@ -409,7 +414,7 @@ export async function createE2ETestContext(options?: { timeout?: number }): Prom
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
const ctx = await startTestDaemon({ timeout: options?.timeout });
|
const ctx = await startTestDaemon({ timeout: options?.timeout, env: options?.env });
|
||||||
|
|
||||||
const paseo = (
|
const paseo = (
|
||||||
args: string[],
|
args: string[],
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ export { getOrCreateServerId } from "./server-id.js";
|
|||||||
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
||||||
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||||
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
||||||
|
export {
|
||||||
|
ConnectionOfferSchema,
|
||||||
|
decodeOfferFragmentPayload,
|
||||||
|
parseConnectionOfferFromUrl,
|
||||||
|
type ConnectionOffer,
|
||||||
|
} from "../shared/connection-offer.js";
|
||||||
|
export { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||||
export {
|
export {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
type DaemonClientConfig,
|
type DaemonClientConfig,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
type DaemonEvent,
|
type DaemonEvent,
|
||||||
} from "../client/daemon-client.js";
|
} from "../client/daemon-client.js";
|
||||||
|
export type { WebSocketFactory, WebSocketLike } from "../client/daemon-client-transport-types.js";
|
||||||
export {
|
export {
|
||||||
ensureLocalSpeechModels,
|
ensureLocalSpeechModels,
|
||||||
listLocalSpeechModels,
|
listLocalSpeechModels,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,3 +20,38 @@ export type ConnectionOfferV2 = z.infer<typeof ConnectionOfferV2Schema>;
|
|||||||
|
|
||||||
export const ConnectionOfferSchema = ConnectionOfferV2Schema;
|
export const ConnectionOfferSchema = ConnectionOfferV2Schema;
|
||||||
export type ConnectionOffer = ConnectionOfferV2;
|
export type ConnectionOffer = ConnectionOfferV2;
|
||||||
|
|
||||||
|
function decodeBase64UrlToUtf8(input: string): string {
|
||||||
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
||||||
|
return Buffer.from(padded, "base64").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||||
|
const json = decodeBase64UrlToUtf8(encoded);
|
||||||
|
return JSON.parse(json) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OFFER_FRAGMENT_PREFIX = "#offer=";
|
||||||
|
|
||||||
|
function extractOfferFragmentEncoded(input: string): string | null {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const fragmentIndex = trimmed.indexOf(OFFER_FRAGMENT_PREFIX);
|
||||||
|
if (fragmentIndex === -1) return null;
|
||||||
|
const encoded = trimmed.slice(fragmentIndex + OFFER_FRAGMENT_PREFIX.length).trim();
|
||||||
|
return encoded.length > 0 ? encoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a pairing-offer URL of the form `https://app.paseo.sh/#offer=<base64url>`.
|
||||||
|
*
|
||||||
|
* Returns `null` if the input has no `#offer=` fragment. Throws if the fragment
|
||||||
|
* exists but the payload is malformed or fails schema validation.
|
||||||
|
*/
|
||||||
|
export function parseConnectionOfferFromUrl(input: string): ConnectionOffer | null {
|
||||||
|
const encoded = extractOfferFragmentEncoded(input);
|
||||||
|
if (!encoded) return null;
|
||||||
|
const payload = decodeOfferFragmentPayload(encoded);
|
||||||
|
return ConnectionOfferSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|||||||
+20
-1
@@ -120,6 +120,25 @@ paseo daemon stop # Stop the daemon
|
|||||||
|
|
||||||
Use `PASEO_HOME` to run multiple isolated daemon instances.
|
Use `PASEO_HOME` to run multiple isolated daemon instances.
|
||||||
|
|
||||||
|
## Connecting to a remote daemon
|
||||||
|
|
||||||
|
`--host` accepts either a local target (`host:port`, a unix socket, or a Windows pipe) or a pairing offer URL — the same `https://app.paseo.sh/#offer=...` link the mobile app uses for QR pairing. With an offer URL the CLI connects through the Paseo relay with end-to-end encryption, so you can drive a daemon on another machine without exposing it to the network.
|
||||||
|
|
||||||
|
Get an offer URL from the daemon you want to control:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paseo daemon pair --json # prints { url, qr, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it from anywhere:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paseo ls --host 'https://app.paseo.sh/#offer=eyJ2IjoyLC...'
|
||||||
|
paseo run --host "$OFFER_URL" "fix the failing tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also set it once via `PASEO_HOST` instead of passing `--host` on every command.
|
||||||
|
|
||||||
## Multi-agent workflows
|
## Multi-agent workflows
|
||||||
|
|
||||||
The CLI is designed to be used by agents themselves. You can instruct an agent to spawn sub-agents for parallel work:
|
The CLI is designed to be used by agents themselves. You can instruct an agent to spawn sub-agents for parallel work:
|
||||||
@@ -160,7 +179,7 @@ paseo ls -q # IDs only (quiet)
|
|||||||
|
|
||||||
## Global options
|
## Global options
|
||||||
|
|
||||||
- `--host <host:port>` — connect to a different daemon
|
- `--host <target>` — connect to a different daemon (`host:port`, unix socket, or `https://app.paseo.sh/#offer=...` for relay). See [Connecting to a remote daemon](#connecting-to-a-remote-daemon).
|
||||||
- `--json` — JSON output
|
- `--json` — JSON output
|
||||||
- `-q, --quiet` — minimal output
|
- `-q, --quiet` — minimal output
|
||||||
- `--no-color` — disable colors
|
- `--no-color` — disable colors
|
||||||
|
|||||||
Reference in New Issue
Block a user