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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -461,6 +461,10 @@ struct SettingsTab: View {
|
||||
self.locationEnabledModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user