iOS/Gateway: harden pairing resolution and settings-driven capability refresh (#22120)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 55b8a93a99
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:
Mariano
2026-02-20 18:57:04 +00:00
committed by GitHub
parent 61f646c41f
commit 5828708343
6 changed files with 82 additions and 5 deletions

View File

@@ -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 },

View File

@@ -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)

View File

@@ -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:/);
});
});