diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 49fd6e13fdc0..06b03e9f9222 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -669,6 +669,7 @@ enum class GatewayEvent( NodeInvokeCancel("node.invoke.cancel"), NodeInvokeInput("node.invoke.input"), NodeInvokeRequest("node.invoke.request"), + DevicePairChanged("device.pair.changed"), DevicePairRequested("device.pair.requested"), DevicePairResolved("device.pair.resolved"), DevicePairSetupCompleted("device.pair.setup.completed"), diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 70cf5d6b4397..4deb6d2c4bd1 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -3,6 +3,7 @@ "ios": { "controlUi.sessionPullRequests.changed": "Sidebar PR indicators are a Control UI surface; native apps do not render session PR chips.", "cron": "Cron run activity is not surfaced in the iOS app.", + "device.pair.changed": "Device label projection refresh is a Control UI surface; iOS does not render the Gateway Devices page.", "device.pair.requested": "Device pairing flows poll via device.pair.* methods on iOS.", "device.pair.resolved": "Device pairing flows poll via device.pair.* methods on iOS.", "device.pair.setup.completed": "Generated setup lifecycle is a Control UI surface; iOS direct Watch setup completes through Watch connectivity.", @@ -36,6 +37,7 @@ "android": { "controlUi.sessionPullRequests.changed": "Sidebar PR indicators are a Control UI surface; native apps do not render session PR chips.", "cron": "Cron run activity is not surfaced in the Android app.", + "device.pair.changed": "Device label projection refresh is a Control UI surface; Android does not render the Gateway Devices page.", "device.pair.requested": "Device pairing flows poll via device.pair.* methods on Android.", "device.pair.resolved": "Device pairing flows poll via device.pair.* methods on Android.", "device.pair.setup.completed": "Generated setup lifecycle is a Control UI surface; Android consumes setup codes but does not issue or track them.", diff --git a/src/gateway/events.ts b/src/gateway/events.ts index 5f0db6879c24..158b234d7d48 100644 --- a/src/gateway/events.ts +++ b/src/gateway/events.ts @@ -6,6 +6,9 @@ import type { import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { READ_SCOPE } from "./operator-scopes.js"; +/** Event name emitted when the paired-device projection changes. */ +export const GATEWAY_EVENT_DEVICE_PAIR_CHANGED = "device.pair.changed" as const; + /** Event name emitted when a node's private runner declaration changes. */ export const GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED = "node.runnerInventory.changed" as const; diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index e1fb82721d61..6598a53023ba 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -530,42 +530,35 @@ describe("gateway broadcaster", () => { }); it("filters approval and pairing events by scope", () => { - const approvalsSocket: TestSocket = { - bufferedAmount: 0, - send: vi.fn(), - close: vi.fn(), - }; - const pairingSocket: TestSocket = { - bufferedAmount: 0, - send: vi.fn(), - close: vi.fn(), - }; - const readSocket: TestSocket = { - bufferedAmount: 0, - send: vi.fn(), - close: vi.fn(), - }; + const approvalsSocket = makeRecordingSocket(); + const pairingSocket = makeRecordingSocket(); + const readSocket = makeRecordingSocket(); + const adminSocket = makeRecordingSocket(); const clients = new Set([ makeOperatorWsClient("c-approvals", approvalsSocket, ["operator.approvals"]), makeOperatorWsClient("c-pairing", pairingSocket, ["operator.pairing"]), makeOperatorWsClient("c-read", readSocket, ["operator.read"]), + makeOperatorWsClient("c-admin", adminSocket, ["operator.admin"]), ]); const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients }); broadcast("exec.approval.requested", { id: "1" }); broadcast("device.pair.requested", { requestId: "r1" }); + broadcast("device.pair.changed", {}); expect(approvalsSocket.send).toHaveBeenCalledTimes(1); - expect(pairingSocket.send).toHaveBeenCalledTimes(1); + expect(pairingSocket.send).toHaveBeenCalledTimes(2); expect(readSocket.send).toHaveBeenCalledTimes(0); + expect(adminSocket.send).toHaveBeenCalledTimes(3); broadcastToConnIds("tick", { ts: 1 }, new Set(["c-read"])); broadcastToConnIds("talk.event", { type: "session.ready" }, new Set(["c-read"])); expect(readSocket.send).toHaveBeenCalledTimes(2); expect(approvalsSocket.send).toHaveBeenCalledTimes(1); - expect(pairingSocket.send).toHaveBeenCalledTimes(1); + expect(pairingSocket.send).toHaveBeenCalledTimes(2); + expect(adminSocket.send).toHaveBeenCalledTimes(3); }); it("requires operator.read for chat-class broadcast events", () => { diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 7c375b301ee5..981dbe0179b8 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -9,7 +9,10 @@ import { logRejectedLargePayload } from "../logging/diagnostic-payload.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { queuePluginSessionsChanged } from "../plugins/gateway-events.js"; import { isBrowserCopilotClient } from "../utils/message-channel.js"; -import { GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED } from "./events.js"; +import { + GATEWAY_EVENT_DEVICE_PAIR_CHANGED, + GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED, +} from "./events.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE, @@ -69,6 +72,7 @@ const EVENT_SCOPE_GUARDS: Record = { "skills.changed": [READ_SCOPE], "voicewake.changed": [READ_SCOPE], "voicewake.routing.changed": [READ_SCOPE], + [GATEWAY_EVENT_DEVICE_PAIR_CHANGED]: [PAIRING_SCOPE], "device.pair.requested": [PAIRING_SCOPE], "device.pair.resolved": [PAIRING_SCOPE], "device.pair.setup.completed": [PAIRING_SCOPE], diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 3957022fc4da..282c2ed22908 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -21,6 +21,7 @@ describe("GATEWAY_EVENTS", () => { it("advertises node topology updates", () => { expect(GATEWAY_EVENTS).toContain("node.presence"); expect(GATEWAY_EVENTS).toContain("device.pair.setup.completed"); + expect(GATEWAY_EVENTS).toContain("device.pair.changed"); expect(GATEWAY_EVENTS).toContain("node.runnerInventory.changed"); }); diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index c2e59d1ca109..c0f64cd286bd 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -2,6 +2,7 @@ // Lists advertised core, auxiliary, channel plugin methods, and websocket events. import { listLoadedChannelPlugins } from "../channels/plugins/registry-loaded.js"; import { + GATEWAY_EVENT_DEVICE_PAIR_CHANGED, GATEWAY_EVENT_NODE_RUNNER_INVENTORY_CHANGED, GATEWAY_EVENT_UPDATE_AVAILABLE, } from "./events.js"; @@ -71,6 +72,7 @@ export const GATEWAY_EVENTS = [ "node.invoke.cancel", "node.invoke.input", "node.invoke.request", + GATEWAY_EVENT_DEVICE_PAIR_CHANGED, "device.pair.requested", "device.pair.resolved", "device.pair.setup.completed", diff --git a/src/gateway/server-methods/devices.test.ts b/src/gateway/server-methods/devices.test.ts index 096609064e78..c38bc1c64a2d 100644 --- a/src/gateway/server-methods/devices.test.ts +++ b/src/gateway/server-methods/devices.test.ts @@ -1657,6 +1657,11 @@ describe("deviceHandlers", () => { expect(opts.context.logGateway.info).toHaveBeenCalledWith( "device pairing renamed device=device-1 label=Kitchen Mac", ); + expect(opts.context.broadcast).toHaveBeenCalledWith( + "device.pair.changed", + {}, + { dropIfSlow: true }, + ); }); it("rejects renaming another device from a non-admin device session", async () => { @@ -1690,6 +1695,7 @@ describe("deviceHandlers", () => { expect(updatePairedDeviceMetadataMock).toHaveBeenCalledWith("missing-device", { operatorLabel: "Ghost", }); + expect(opts.context.broadcast).not.toHaveBeenCalled(); expectRespondedErrorMessage(opts, "unknown deviceId"); }); diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 638e17c28fc1..97c68535049b 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -32,6 +32,7 @@ import { } from "../../infra/device-pairing.js"; import type { DiagnosticSecurityEventInput } from "../../infra/diagnostic-events.js"; import { reconcileRevokedDeviceWorker } from "../device-worker-revocation.js"; +import { GATEWAY_EVENT_DEVICE_PAIR_CHANGED } from "../events.js"; import { clearRemovedNodeRuntimeState } from "../node-runtime-state.js"; import { invalidateNodeWakeState } from "../node-wake-state.js"; import { @@ -551,6 +552,7 @@ export const deviceHandlers: GatewayRequestHandlers = { targetDeviceId: deviceId, controlId: "device.pair.rename", }); + context.broadcast(GATEWAY_EVENT_DEVICE_PAIR_CHANGED, {}, { dropIfSlow: true }); respond(true, { deviceId, label: trimmed }, undefined); }, "device.token.rotate": async ({ params, respond, context, client }) => { diff --git a/ui/src/pages/devices/devices-page.test.ts b/ui/src/pages/devices/devices-page.test.ts index 10ad47d9a6f5..5613801496cc 100644 --- a/ui/src/pages/devices/devices-page.test.ts +++ b/ui/src/pages/devices/devices-page.test.ts @@ -6,7 +6,6 @@ import type { PresenceEntry } from "../../api/types.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import { t } from "../../i18n/index.ts"; import { - approveDevicePairing, createInitialDevicesState, loadDevices, loadNodes, @@ -266,12 +265,15 @@ describe("DevicesPage gateway lifecycle", () => { page.remove(); }); - it("coalesces a device refresh requested while an older list is loading", async () => { + it("refetches a changed device label after an older list response", async () => { const stale = deferred<{ - paired: []; - pending: Array<{ requestId: string; deviceId: string }>; + paired: Array<{ deviceId: string; displayName: string }>; + pending: []; + }>(); + const refreshed = deferred<{ + paired: Array<{ deviceId: string; displayName: string; operatorLabel: string }>; + pending: []; }>(); - const refreshed = deferred<{ paired: []; pending: [] }>(); let listCalls = 0; const request = vi.fn((method: string) => { if (method === "device.pair.list") { @@ -281,23 +283,55 @@ describe("DevicesPage gateway lifecycle", () => { return Promise.resolve({}); }); const client = { request } as unknown as GatewayBrowserClient; - const state = createInitialDevicesState({ client, connected: true }); + const snapshot = { + ...gatewaySnapshot(client, true), + hello: { + type: "hello-ok", + protocol: 1, + auth: { role: "operator", scopes: ["operator.pairing"] }, + features: { methods: ["device.pair.list"] }, + }, + } as ApplicationGatewaySnapshot; + let onEvent: ((event: { event: string; payload?: unknown }) => void) | undefined; + const currentGateway = gateway(client, snapshot); + currentGateway.subscribeEvents = vi.fn((listener) => { + onEvent = listener as typeof onEvent; + return () => undefined; + }); + const page = document.createElement("openclaw-devices-page") as TestDevicesPage; + page.context = { + gateway: currentGateway, + runtimeConfig: { + state: { configSnapshot: {}, configLoading: false }, + subscribe: vi.fn(() => () => undefined), + }, + } as unknown as ApplicationContext; + page.pageState = createInitialDevicesState({ client, connected: true }); + document.body.append(page); + await vi.waitFor(() => expect(onEvent).toBeDefined()); - const initialLoad = loadDevices(state); + const initialLoad = loadDevices(page.pageState); await vi.waitFor(() => expect(request).toHaveBeenCalledWith("device.pair.list", {})); - const approval = approveDevicePairing(state, "request-1"); - await vi.waitFor(() => - expect(request).toHaveBeenCalledWith("device.pair.approve", { requestId: "request-1" }), - ); + onEvent?.({ event: "device.pair.changed", payload: {} }); - stale.resolve({ paired: [], pending: [{ requestId: "request-1", deviceId: "device-1" }] }); + stale.resolve({ + paired: [{ deviceId: "device-1", displayName: "Kitchen Mac" }], + pending: [], + }); await vi.waitFor(() => expect(listCalls).toBe(2)); - expect(state.devicesLoading).toBe(true); + expect(page.pageState.devicesLoading).toBe(true); - refreshed.resolve({ paired: [], pending: [] }); - await Promise.all([initialLoad, approval]); - expect(state.devicesList).toEqual({ paired: [], pending: [] }); - expect(state.devicesLoading).toBe(false); + refreshed.resolve({ + paired: [{ deviceId: "device-1", displayName: "Kitchen Mac", operatorLabel: "Studio Mac" }], + pending: [], + }); + await initialLoad; + expect(page.pageState.devicesList).toEqual({ + paired: [{ deviceId: "device-1", displayName: "Kitchen Mac", operatorLabel: "Studio Mac" }], + pending: [], + }); + expect(page.pageState.devicesLoading).toBe(false); + page.remove(); }); it("coalesces a node refresh requested while an older list is loading", async () => { @@ -357,7 +391,7 @@ describe("DevicesPage gateway lifecycle", () => { expect(request.mock.calls.map(([method]) => method)).not.toContain("exec.approvals.get"); }); - it("keeps presence-driven device reloads gated on pairing access", async () => { + it("keeps event-driven device reloads gated on pairing access", async () => { const request = vi.fn(async (method: string) => (method === "node.list" ? { nodes: [] } : {})); const client = { request } as unknown as GatewayBrowserClient; const snapshot = { @@ -398,6 +432,10 @@ describe("DevicesPage gateway lifecycle", () => { ).toBeGreaterThan(nodeListCallsBefore.length), ); expect(request.mock.calls.map(([method]) => method)).not.toContain("device.pair.list"); + + onEvent?.({ event: "device.pair.changed", payload: {} }); + await Promise.resolve(); + expect(request.mock.calls.map(([method]) => method)).not.toContain("device.pair.list"); page.remove(); }); diff --git a/ui/src/pages/devices/devices-page.ts b/ui/src/pages/devices/devices-page.ts index 7b3057081397..09cebc5b8f9a 100644 --- a/ui/src/pages/devices/devices-page.ts +++ b/ui/src/pages/devices/devices-page.ts @@ -2,6 +2,7 @@ import { consume } from "@lit/context"; import { initialState, Task } from "@lit/task"; import { html, type PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; +import { GATEWAY_EVENT_DEVICE_PAIR_CHANGED } from "../../../../src/gateway/events.js"; import type { PresenceEntry } from "../../api/types.ts"; import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; import { @@ -166,7 +167,11 @@ class DevicesPage extends OpenClawLightDomElement { void this.runPageTask((pageState) => loadNodes(pageState, { quiet: true })); } } - if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") { + if ( + event.event === GATEWAY_EVENT_DEVICE_PAIR_CHANGED || + event.event === "device.pair.requested" || + event.event === "device.pair.resolved" + ) { if (this.canManagePairing) { void this.runPageTask((pageState) => loadDevices(pageState, { quiet: true })); }