Skip to content

Commit 41bbd41

Browse files
committed
gateway+ios: harden pairing resolution and capability refresh
1 parent 84281ab commit 41bbd41

File tree

5 files changed

+81
-5
lines changed

5 files changed

+81
-5
lines changed

apps/ios/Sources/Gateway/GatewayConnectionController.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,23 @@ final class GatewayConnectionController {
216216
}
217217
}
218218

219+
/// Rebuild connect options from current local settings (caps/commands/permissions)
220+
/// and re-apply the active gateway config so capability changes take effect immediately.
221+
func refreshActiveGatewayRegistrationFromSettings() {
222+
guard let appModel else { return }
223+
guard let cfg = appModel.activeGatewayConnectConfig else { return }
224+
guard appModel.gatewayAutoReconnectEnabled else { return }
225+
226+
let refreshedConfig = GatewayConnectConfig(
227+
url: cfg.url,
228+
stableID: cfg.stableID,
229+
tls: cfg.tls,
230+
token: cfg.token,
231+
password: cfg.password,
232+
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
233+
appModel.applyGatewayConnectConfig(refreshedConfig)
234+
}
235+
219236
func clearPendingTrustPrompt() {
220237
self.pendingTrustPrompt = nil
221238
self.pendingTrustConnect = nil

apps/ios/Sources/Settings/SettingsTab.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,10 @@ struct SettingsTab: View {
461461
self.locationEnabledModeRaw = previous
462462
self.lastLocationModeRaw = previous
463463
}
464+
return
465+
}
466+
await MainActor.run {
467+
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
464468
}
465469
}
466470
}

src/agents/tools/nodes-tool.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const;
4848
const CAMERA_FACING = ["front", "back", "both"] as const;
4949
const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const;
5050

51+
function isPairingRequiredMessage(message: string): boolean {
52+
const lower = message.toLowerCase();
53+
return lower.includes("pairing required") || lower.includes("not_paired");
54+
}
55+
56+
function extractPairingRequestId(message: string): string | null {
57+
const match = message.match(/\(requestId:\s*([^)]+)\)/i);
58+
if (!match) {
59+
return null;
60+
}
61+
const value = (match[1] ?? "").trim();
62+
return value.length > 0 ? value : null;
63+
}
64+
5165
// Flattened schema: runtime validates per-action requirements.
5266
const NodesToolSchema = Type.Object({
5367
action: stringEnum(NODES_TOOL_ACTIONS),
@@ -544,7 +558,14 @@ export function createNodesTool(options?: {
544558
? gatewayOpts.gatewayUrl.trim()
545559
: "default";
546560
const agentLabel = agentId ?? "unknown";
547-
const message = err instanceof Error ? err.message : String(err);
561+
let message = err instanceof Error ? err.message : String(err);
562+
if (action === "invoke" && isPairingRequiredMessage(message)) {
563+
const requestId = extractPairingRequestId(message);
564+
const approveHint = requestId
565+
? `Approve pairing request ${requestId} and retry.`
566+
: "Approve the pending pairing request and retry.";
567+
message = `pairing required before node invoke. ${approveHint}`;
568+
}
548569
throw new Error(
549570
`agent=${agentLabel} node=${nodeLabel} gateway=${gatewayLabel} action=${action}: ${message}`,
550571
{ cause: err },

src/shared/node-match.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type NodeMatchCandidate = {
22
nodeId: string;
33
displayName?: string;
44
remoteIp?: string;
5+
connected?: boolean;
56
};
67

78
export function normalizeNodeKey(value: string) {
@@ -53,14 +54,23 @@ export function resolveNodeIdFromCandidates(nodes: NodeMatchCandidate[], query:
5354
throw new Error("node required");
5455
}
5556

56-
const matches = resolveNodeMatches(nodes, q);
57-
if (matches.length === 1) {
58-
return matches[0]?.nodeId ?? "";
57+
const rawMatches = resolveNodeMatches(nodes, q);
58+
if (rawMatches.length === 1) {
59+
return rawMatches[0]?.nodeId ?? "";
5960
}
60-
if (matches.length === 0) {
61+
if (rawMatches.length === 0) {
6162
const known = listKnownNodes(nodes);
6263
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
6364
}
65+
66+
// Re-pair/reinstall flows can leave multiple nodes with the same display name.
67+
// Prefer a unique connected match when available.
68+
const connectedMatches = rawMatches.filter((match) => match.connected === true);
69+
const matches = connectedMatches.length > 0 ? connectedMatches : rawMatches;
70+
if (matches.length === 1) {
71+
return matches[0]?.nodeId ?? "";
72+
}
73+
6474
throw new Error(
6575
`ambiguous node: ${q} (matches: ${matches
6676
.map((n) => n.displayName || n.remoteIp || n.nodeId)

src/shared/shared-misc.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,28 @@ describe("resolveNodeIdFromCandidates", () => {
124124
resolveNodeIdFromCandidates([{ nodeId: "mac-abcdef" }, { nodeId: "mac-abc999" }], "mac-abc"),
125125
).toThrow(/ambiguous node: mac-abc.*matches:/);
126126
});
127+
128+
it("prefers a unique connected node when names are duplicated", () => {
129+
expect(
130+
resolveNodeIdFromCandidates(
131+
[
132+
{ nodeId: "ios-old", displayName: "iPhone", connected: false },
133+
{ nodeId: "ios-live", displayName: "iPhone", connected: true },
134+
],
135+
"iphone",
136+
),
137+
).toBe("ios-live");
138+
});
139+
140+
it("stays ambiguous when multiple connected nodes match", () => {
141+
expect(() =>
142+
resolveNodeIdFromCandidates(
143+
[
144+
{ nodeId: "ios-a", displayName: "iPhone", connected: true },
145+
{ nodeId: "ios-b", displayName: "iPhone", connected: true },
146+
],
147+
"iphone",
148+
),
149+
).toThrow(/ambiguous node: iphone.*matches:/);
150+
});
127151
});

0 commit comments

Comments
 (0)