From 5828708343080774c6c19eaaf1bf83e257a2b0eb Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:57:04 +0000 Subject: [PATCH] iOS/Gateway: harden pairing resolution and settings-driven capability refresh (#22120) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 55b8a93a999b7458c98f9d3b31abbd3665929b31 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + .../Gateway/GatewayConnectionController.swift | 17 +++++++++++++ apps/ios/Sources/Settings/SettingsTab.swift | 4 ++++ src/agents/tools/nodes-tool.ts | 23 +++++++++++++++++- src/shared/node-match.ts | 18 ++++++++++---- src/shared/shared-misc.test.ts | 24 +++++++++++++++++++ 6 files changed, 82 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1496713c276..388eb1d2f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky. - CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford. - Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc. +- iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable `nodes invoke` pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky. ## 2026.2.19 diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 109defac3f0..acfb9aab358 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -216,6 +216,23 @@ final class GatewayConnectionController { } } + /// Rebuild connect options from current local settings (caps/commands/permissions) + /// and re-apply the active gateway config so capability changes take effect immediately. + func refreshActiveGatewayRegistrationFromSettings() { + guard let appModel else { return } + guard let cfg = appModel.activeGatewayConnectConfig else { return } + guard appModel.gatewayAutoReconnectEnabled else { return } + + let refreshedConfig = GatewayConnectConfig( + url: cfg.url, + stableID: cfg.stableID, + tls: cfg.tls, + token: cfg.token, + password: cfg.password, + nodeOptions: self.makeConnectOptions(stableID: cfg.stableID)) + appModel.applyGatewayConnectConfig(refreshedConfig) + } + func clearPendingTrustPrompt() { self.pendingTrustPrompt = nil self.pendingTrustConnect = nil diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 7825b45cb8d..a74f2fed952 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -461,6 +461,10 @@ struct SettingsTab: View { self.locationEnabledModeRaw = previous self.lastLocationModeRaw = previous } + return + } + await MainActor.run { + self.gatewayController.refreshActiveGatewayRegistrationFromSettings() } } } diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 2bda0d4a854..3188d7dc1b8 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -48,6 +48,20 @@ const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const; const CAMERA_FACING = ["front", "back", "both"] as const; const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const; +function isPairingRequiredMessage(message: string): boolean { + const lower = message.toLowerCase(); + return lower.includes("pairing required") || lower.includes("not_paired"); +} + +function extractPairingRequestId(message: string): string | null { + const match = message.match(/\(requestId:\s*([^)]+)\)/i); + if (!match) { + return null; + } + const value = (match[1] ?? "").trim(); + return value.length > 0 ? value : null; +} + // Flattened schema: runtime validates per-action requirements. const NodesToolSchema = Type.Object({ action: stringEnum(NODES_TOOL_ACTIONS), @@ -544,7 +558,14 @@ export function createNodesTool(options?: { ? gatewayOpts.gatewayUrl.trim() : "default"; const agentLabel = agentId ?? "unknown"; - const message = err instanceof Error ? err.message : String(err); + let message = err instanceof Error ? err.message : String(err); + if (action === "invoke" && isPairingRequiredMessage(message)) { + const requestId = extractPairingRequestId(message); + const approveHint = requestId + ? `Approve pairing request ${requestId} and retry.` + : "Approve the pending pairing request and retry."; + message = `pairing required before node invoke. ${approveHint}`; + } throw new Error( `agent=${agentLabel} node=${nodeLabel} gateway=${gatewayLabel} action=${action}: ${message}`, { cause: err }, diff --git a/src/shared/node-match.ts b/src/shared/node-match.ts index cc4f5233999..ba082635dea 100644 --- a/src/shared/node-match.ts +++ b/src/shared/node-match.ts @@ -2,6 +2,7 @@ export type NodeMatchCandidate = { nodeId: string; displayName?: string; remoteIp?: string; + connected?: boolean; }; export function normalizeNodeKey(value: string) { @@ -53,14 +54,23 @@ export function resolveNodeIdFromCandidates(nodes: NodeMatchCandidate[], query: throw new Error("node required"); } - const matches = resolveNodeMatches(nodes, q); - if (matches.length === 1) { - return matches[0]?.nodeId ?? ""; + const rawMatches = resolveNodeMatches(nodes, q); + if (rawMatches.length === 1) { + return rawMatches[0]?.nodeId ?? ""; } - if (matches.length === 0) { + if (rawMatches.length === 0) { const known = listKnownNodes(nodes); throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`); } + + // Re-pair/reinstall flows can leave multiple nodes with the same display name. + // Prefer a unique connected match when available. + const connectedMatches = rawMatches.filter((match) => match.connected === true); + const matches = connectedMatches.length > 0 ? connectedMatches : rawMatches; + if (matches.length === 1) { + return matches[0]?.nodeId ?? ""; + } + throw new Error( `ambiguous node: ${q} (matches: ${matches .map((n) => n.displayName || n.remoteIp || n.nodeId) diff --git a/src/shared/shared-misc.test.ts b/src/shared/shared-misc.test.ts index 9ac04ca6235..8a729109513 100644 --- a/src/shared/shared-misc.test.ts +++ b/src/shared/shared-misc.test.ts @@ -124,4 +124,28 @@ describe("resolveNodeIdFromCandidates", () => { resolveNodeIdFromCandidates([{ nodeId: "mac-abcdef" }, { nodeId: "mac-abc999" }], "mac-abc"), ).toThrow(/ambiguous node: mac-abc.*matches:/); }); + + it("prefers a unique connected node when names are duplicated", () => { + expect( + resolveNodeIdFromCandidates( + [ + { nodeId: "ios-old", displayName: "iPhone", connected: false }, + { nodeId: "ios-live", displayName: "iPhone", connected: true }, + ], + "iphone", + ), + ).toBe("ios-live"); + }); + + it("stays ambiguous when multiple connected nodes match", () => { + expect(() => + resolveNodeIdFromCandidates( + [ + { nodeId: "ios-a", displayName: "iPhone", connected: true }, + { nodeId: "ios-b", displayName: "iPhone", connected: true }, + ], + "iphone", + ), + ).toThrow(/ambiguous node: iphone.*matches:/); + }); });