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
+3
View File
@@ -0,0 +1,3 @@
export function daemonPairingOfferQueryKey(serverId: string) {
return ["daemon-pairing-offer", serverId] as const;
}
@@ -4,6 +4,7 @@ import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/prot
import { checkoutDiffQueryKey } from "@/git/query-keys";
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
import { providersSnapshotQueryKey } from "@/data/providers-snapshot";
import {
checkoutDiffPushRoute,
@@ -34,6 +35,7 @@ type RouterHandler = (message: RouterMessage) => void;
type RouterClient = Parameters<typeof mountServerDataPushRouter>[0]["client"];
const daemonConfig: MutableDaemonConfig = {
relay: { enabled: false },
mcp: { injectIntoAgents: true },
browserTools: { enabled: false },
providers: {},
@@ -145,6 +147,8 @@ describe("server data push router", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const pairingOfferKey = daemonPairingOfferQueryKey(serverId);
queryClient.setQueryData(pairingOfferKey, { relayEnabled: true, url: "https://pairing" });
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
fake.emit(providerUpdate("2026-01-01T00:00:00.000Z"));
@@ -159,6 +163,7 @@ describe("server data push router", () => {
requestId: "providers_snapshot_update",
});
expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig);
expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true);
unmount();
fake.emit(providerUpdate("2026-01-01T00:00:01.000Z"));
@@ -502,12 +507,14 @@ describe("server data push router", () => {
const otherServerId = "server-2";
const providerKey = providersSnapshotQueryKey(serverId);
const daemonConfigKey = daemonConfigQueryKey(serverId);
const pairingOfferKey = daemonPairingOfferQueryKey(serverId);
const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false);
const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a");
const otherProviderKey = providersSnapshotQueryKey(otherServerId);
queryClient.setQueryData(providerKey, { entries: [], generatedAt: "now", requestId: "p" });
queryClient.setQueryData(daemonConfigKey, daemonConfig);
queryClient.setQueryData(pairingOfferKey, { relayEnabled: false, url: "" });
queryClient.setQueryData(diffKey, { cwd: "/repo", files: [], error: null, requestId: "d" });
queryClient.setQueryData(terminalKey, { cwd: "/repo", terminals: [], requestId: "t" });
queryClient.setQueryData(otherProviderKey, {
@@ -520,6 +527,7 @@ describe("server data push router", () => {
expect(queryClient.getQueryState(providerKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(daemonConfigKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(diffKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(terminalKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherProviderKey)?.isInvalidated).toBe(false);
+10
View File
@@ -7,6 +7,7 @@ import type {
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
import { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
type ProvidersSnapshotUpdateMessage = Extract<
@@ -105,6 +106,12 @@ const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [
void queryClient.invalidateQueries({ queryKey: daemonConfigQueryKey(serverId) });
},
},
{
domain: "daemonPairingOffer",
invalidate: ({ queryClient, serverId }) => {
void queryClient.invalidateQueries({ queryKey: daemonPairingOfferQueryKey(serverId) });
},
},
{
domain: "checkoutDiff",
invalidate: ({ queryClient, serverId }) => {
@@ -403,6 +410,9 @@ function applyDaemonConfigStatus(input: {
daemonConfigQueryKey(input.serverId),
payload.config,
);
void input.queryClient.invalidateQueries({
queryKey: daemonPairingOfferQueryKey(input.serverId),
});
}
function applyCheckoutDiffUpdate(input: {