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
@@ -132,6 +132,7 @@ export function OpenProjectScreen() {
<CommunityLinks />
</View>
<PairDeviceModal
serverId={localServerId ?? ""}
visible={isPairDeviceOpen}
onClose={handleClosePairDevice}
testID="open-project-pair-device-modal"
+6 -2
View File
@@ -34,6 +34,7 @@ import {
FolderGit2,
SquareTerminal,
Code2,
Smartphone,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -95,6 +96,7 @@ import {
} from "@/i18n/locales";
import {
HostConnectionsPage,
HostPairDevicePage,
HostAgentsPage,
HostSettingsPage,
HostProvidersPage,
@@ -167,6 +169,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
{ id: "projects", labelKey: "settings.hostSections.projects", icon: FolderGit2 },
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
@@ -183,6 +186,8 @@ function renderHostSettingsContent(
return <ProjectsScreen serverId={view.serverId} />;
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "pair-device":
return <HostPairDevicePage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "workspaces":
@@ -1262,8 +1267,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
if (view.kind !== "host") {
return;
}
const section: HostSectionSlug = view.section;
const target = buildSettingsHostSectionRoute(serverId, section);
const target = buildSettingsHostSectionRoute(serverId, view.section);
if (isCompactLayout) {
router.push(target);
} else {
@@ -9,6 +9,7 @@ import {
function makeConfig(browserToolsEnabled = false): MutableDaemonConfig {
return {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: browserToolsEnabled },
providers: {},
@@ -237,9 +237,7 @@ function HostConnectionError({ serverId }: { serverId: string }) {
}
export function HostConnectionsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
@@ -249,15 +247,25 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) {
<View>
<HostConnectionError serverId={serverId} />
<ConnectionsSection host={host} />
{isLocalDaemon ? (
<SettingsSection title={t("settings.host.pairDevices.title")}>
<PairDeviceRow />
</SettingsSection>
) : null}
</View>
);
}
export function HostPairDevicePage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<SettingsSection title={t("settings.host.pairDevices.title")}>
<PairDeviceRow serverId={serverId} />
</SettingsSection>
);
}
export function HostAgentsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const host = useHostProfile(serverId);
@@ -1241,7 +1249,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
);
}
function PairDeviceRow() {
function PairDeviceRow({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -1265,6 +1273,7 @@ function PairDeviceRow() {
</Pressable>
<PairDeviceModal
serverId={serverId}
visible={isModalOpen}
onClose={handleClose}
testID="host-page-pair-device-card"
@@ -304,6 +304,7 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers,