diff --git a/apps/android/README.md b/apps/android/README.md index d9e622ea6a6..3eb5d9eb763 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -285,7 +285,7 @@ Common failure quick-fixes: - `pairing required` before tests start: - approve pending device pairing (`openclaw devices approve --latest`) and rerun. - `A2UI host not reachable` / `A2UI_HOST_NOT_CONFIGURED`: - - ensure gateway canvas host is running and reachable, keep the app on the **Screen** tab. The app will auto-refresh canvas capability once; if it still fails, reconnect app and rerun. + - ensure the Canvas plugin host is running and reachable, keep the app on the **Screen** tab. The app refreshes the Canvas plugin surface URL once before failing; if it still fails, reconnect app and rerun. - `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`: - app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 7179bba1dbd..42e7ab614d9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -239,6 +239,7 @@ class NodeRuntime( _canvasRehydrateErrorText.value = null }, onCanvasA2uiReset = { _canvasA2uiHydrated.value = false }, + refreshCanvasHostUrl = { nodeSession.refreshCanvasHostUrl() }, motionActivityAvailable = { motionHandler.isActivityAvailable() }, motionPedometerAvailable = { motionHandler.isPedometerAvailable() }, ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 1dce4a46ab8..1ce8d7382c0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -198,6 +198,24 @@ class GatewaySession( fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"] + suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? { + val refreshed = + refreshPluginSurfaceUrl( + method = "node.pluginSurface.refresh", + params = buildJsonObject { put("surface", JsonPrimitive("canvas")) }, + timeoutMs = timeoutMs, + ) + ?: refreshPluginSurfaceUrl( + method = "node.canvas.capability.refresh", + params = null, + timeoutMs = timeoutMs, + ) + if (!refreshed.isNullOrBlank()) { + pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to refreshed) + } + return refreshed + } + fun currentMainSessionKey(): String? = mainSessionKey suspend fun sendNodeEvent( @@ -218,6 +236,29 @@ class GatewaySession( } } + private suspend fun refreshPluginSurfaceUrl( + method: String, + params: JsonElement?, + timeoutMs: Long, + ): String? { + val conn = currentConnection ?: return null + return try { + val res = conn.request(method, params, timeoutMs) + if (!res.ok) return null + val obj = res.payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } ?: return null + val raw = + obj["pluginSurfaceUrls"] + .asObjectOrNull() + ?.get("canvas") + .asStringOrNull() + ?: obj["canvasHostUrl"].asStringOrNull() + normalizeCanvasHostUrl(raw, conn.endpoint, isTlsConnection = conn.tls != null) + } catch (err: Throwable) { + Log.d("OpenClawGateway", "$method failed: ${err.message ?: err::class.java.simpleName}") + null + } + } + suspend fun sendNodeEventDetailed( event: String, payloadJson: String?, @@ -288,12 +329,12 @@ class GatewaySession( ) private inner class Connection( - private val endpoint: GatewayEndpoint, + val endpoint: GatewayEndpoint, private val token: String?, private val bootstrapToken: String?, private val password: String?, private val options: GatewayConnectOptions, - private val tls: GatewayTlsParams?, + val tls: GatewayTlsParams?, ) { private val connectDeferred = CompletableDeferred() private val closedDeferred = CompletableDeferred() @@ -569,15 +610,19 @@ class GatewaySession( } } } - pluginSurfaceUrls = - obj["pluginSurfaceUrls"] - .asObjectOrNull() - ?.mapNotNull { (surface, value) -> - normalizeCanvasHostUrl(value.asStringOrNull(), endpoint, isTlsConnection = tls != null) - ?.let { normalized -> surface to normalized } + val rawPluginSurfaceUrls = obj["pluginSurfaceUrls"].asObjectOrNull() + val normalizedPluginSurfaceUrls = + rawPluginSurfaceUrls?.mapNotNull { (surface, value) -> + normalizeCanvasHostUrl(value.asStringOrNull(), endpoint, isTlsConnection = tls != null) + ?.let { normalized -> surface to normalized } + } ?: emptyList() + pluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap() + if ("canvas" !in pluginSurfaceUrls) { + normalizeCanvasHostUrl(obj["canvasHostUrl"].asStringOrNull(), endpoint, isTlsConnection = tls != null) + ?.let { legacyCanvasHostUrl -> + pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to legacyCanvasHostUrl) } - ?.toMap() - ?: emptyMap() + } val sessionDefaults = obj["snapshot"] .asObjectOrNull() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt index 6edb5ab2dde..b6afaf8256a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt @@ -80,6 +80,7 @@ class InvokeDispatcher( private val debugBuild: () -> Boolean, private val onCanvasA2uiPush: () -> Unit, private val onCanvasA2uiReset: () -> Unit, + private val refreshCanvasHostUrl: suspend () -> String?, private val motionActivityAvailable: () -> Boolean, private val motionPedometerAvailable: () -> Boolean, ) { @@ -228,18 +229,23 @@ class InvokeDispatcher( } private suspend fun withReadyA2ui(block: suspend () -> GatewaySession.InvokeResult): GatewaySession.InvokeResult { - val a2uiUrl = + var a2uiUrl = a2uiHandler.resolveA2uiHostUrl() + ?: refreshCanvasHostUrl().let { a2uiHandler.resolveA2uiHostUrl() } ?: return GatewaySession.InvokeResult.error( code = "A2UI_HOST_NOT_CONFIGURED", message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", ) val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl) if (!readyOnFirstCheck) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable", - ) + refreshCanvasHostUrl() + a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: a2uiUrl + if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) { + return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_UNAVAILABLE", + message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable", + ) + } } return block() } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt index 4135b75d92a..0b79cbbd454 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTimeoutTest.kt @@ -39,5 +39,4 @@ class GatewaySessionInvokeTimeoutTest { assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(121_000L)) assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(Long.MAX_VALUE)) } - } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt index 4089cf5d48c..80bacc6efe5 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt @@ -288,6 +288,7 @@ class InvokeDispatcherTest { debugBuild = { debugBuild }, onCanvasA2uiPush = {}, onCanvasA2uiReset = {}, + refreshCanvasHostUrl = { null }, motionActivityAvailable = { motionActivityAvailable }, motionPedometerAvailable = { motionPedometerAvailable }, ) diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 9f7f57fc30a..9259fe96459 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -56,13 +56,20 @@ extension NodeAppModel { } func ensureA2UIReadyWithCapabilityRefresh(timeoutMs: Int = 5000) async -> A2UIReadyState { - guard let initialUrl = await self.resolveA2UIHostURL() else { + guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else { return .hostNotConfigured } self.screen.navigate(to: initialUrl, trustA2UIActions: true) if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) { return .ready(initialUrl) } + guard let refreshedUrl = await self.resolveA2UIHostURLWithCapabilityRefresh(forceRefresh: true) else { + return .hostUnavailable + } + self.screen.navigate(to: refreshedUrl, trustA2UIActions: true) + if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) { + return .ready(refreshedUrl) + } return .hostUnavailable } @@ -71,11 +78,19 @@ extension NodeAppModel { self.screen.showDefaultCanvas() } - private func resolveA2UIHostURLWithCapabilityRefresh() async -> String? { + private func resolveA2UIHostURLWithCapabilityRefresh(forceRefresh: Bool = false) async -> String? { + if !forceRefresh, let current = await self.resolveA2UIHostURL() { + return current + } + _ = await self.gatewaySession.refreshCanvasHostUrl() return await self.resolveA2UIHostURL() } - private func resolveCanvasHostURLWithCapabilityRefresh() async -> String? { + private func resolveCanvasHostURLWithCapabilityRefresh(forceRefresh: Bool = false) async -> String? { + if !forceRefresh, let current = await self.resolveCanvasHostURL() { + return current + } + _ = await self.gatewaySession.refreshCanvasHostUrl() return await self.resolveCanvasHostURL() } diff --git a/apps/macos/Sources/OpenClaw/CanvasManager.swift b/apps/macos/Sources/OpenClaw/CanvasManager.swift index 9ba2b4426e7..dfd690111af 100644 --- a/apps/macos/Sources/OpenClaw/CanvasManager.swift +++ b/apps/macos/Sources/OpenClaw/CanvasManager.swift @@ -154,7 +154,7 @@ final class CanvasManager { guard case let .snapshot(snapshot) = push else { return } let raw = (snapshot.pluginsurfaceurls?["canvas"]?.value as? String)? - .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" if raw.isEmpty { Self.logger.debug("canvas plugin surface URL missing in gateway snapshot") } else { diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 8d3d3c88652..09e1bf265ee 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -111,6 +111,12 @@ actor GatewayConnection { private var subscribers: [UUID: AsyncStream.Continuation] = [:] private var lastSnapshot: HelloOk? + private var canvasPluginSurfaceUrlOverride: String? + + private struct PluginSurfaceRefreshResponse: Decodable { + let pluginSurfaceUrls: [String: AnyCodable]? + let canvasHostUrl: String? + } private struct LossyDecodable: Decodable { let value: Value? @@ -309,16 +315,56 @@ actor GatewayConnection { self.configuredURL = nil self.configuredToken = nil self.lastSnapshot = nil + self.canvasPluginSurfaceUrlOverride = nil } func canvasPluginSurfaceUrl() async -> String? { + if let override = self.canvasPluginSurfaceUrlOverride { + let trimmed = override.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } guard let snapshot = self.lastSnapshot else { return nil } - let trimmed = - (snapshot.pluginsurfaceurls?["canvas"]?.value as? String)? - .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" + let raw = (snapshot.pluginsurfaceurls?["canvas"]?.value as? String) ?? snapshot.canvashosturl + let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } + @discardableResult + func refreshCanvasPluginSurfaceUrl(timeoutMs: Double = 8000) async -> String? { + if let refreshed = await self.refreshPluginSurfaceUrl( + method: "node.pluginSurface.refresh", + params: ["surface": AnyCodable("canvas")], + timeoutMs: timeoutMs) + { + return refreshed + } + return await self.refreshPluginSurfaceUrl( + method: "node.canvas.capability.refresh", + params: nil, + timeoutMs: timeoutMs) + } + + private func refreshPluginSurfaceUrl( + method: String, + params: [String: AnyCodable]?, + timeoutMs: Double) async -> String? + { + do { + let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) + let decoded = try self.decoder.decode(PluginSurfaceRefreshResponse.self, from: data) + let raw = (decoded.pluginSurfaceUrls?["canvas"]?.value as? String) ?? decoded.canvasHostUrl + let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { + self.canvasPluginSurfaceUrlOverride = trimmed + } + return trimmed.isEmpty ? nil : trimmed + } catch { + gatewayConnectionLogger.debug( + "\(method, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + private func sessionDefaultString(_ defaults: [String: OpenClawProtocol.AnyCodable]?, key: String) -> String { let raw = defaults?[key]?.value as? String return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) @@ -368,6 +414,7 @@ actor GatewayConnection { private func broadcast(_ push: GatewayPush) { if case let .snapshot(snapshot) = push { self.lastSnapshot = snapshot + self.canvasPluginSurfaceUrlOverride = nil if let mainSessionKey = self.cachedMainSessionKey() { Task { @MainActor in WorkActivityStore.shared.setMainSessionKey(mainSessionKey) diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 3fb409714b2..da027aca917 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -441,7 +441,7 @@ actor MacNodeRuntime { private func ensureA2UIHost() async throws { if await self.isA2UIReady() { return } - guard let a2uiUrl = await self.resolveA2UIHostUrl() else { + guard let a2uiUrl = await self.resolveA2UIHostUrlWithCapabilityRefresh() else { throw NSError(domain: "Canvas", code: 30, userInfo: [ NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", ]) @@ -451,6 +451,12 @@ actor MacNodeRuntime { try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl) } if await self.isA2UIReady(poll: true) { return } + if let refreshedUrl = await self.resolveA2UIHostUrlWithCapabilityRefresh(forceRefresh: true) { + _ = try await MainActor.run { + try CanvasManager.shared.show(sessionKey: sessionKey, path: refreshedUrl) + } + if await self.isA2UIReady(poll: true) { return } + } throw NSError(domain: "Canvas", code: 31, userInfo: [ NSLocalizedDescriptionKey: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable", ]) @@ -463,6 +469,14 @@ actor MacNodeRuntime { return baseUrl.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=macos" } + private func resolveA2UIHostUrlWithCapabilityRefresh(forceRefresh: Bool = false) async -> String? { + if !forceRefresh, let current = await self.resolveA2UIHostUrl() { + return current + } + _ = await GatewayConnection.shared.refreshCanvasPluginSurfaceUrl() + return await self.resolveA2UIHostUrl() + } + private func isA2UIReady(poll: Bool = false) async -> Bool { let deadline = poll ? Date().addingTimeInterval(6.0) : Date() while true { diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index bf8d4d40a13..859c95d1d12 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -23,6 +23,7 @@ struct MacGatewayChatTransportMappingTests { features: [:], snapshot: snapshot, pluginsurfaceurls: nil, + canvashosturl: nil, auth: [:], policy: [:]) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 5452a997ce7..6c829e9b290 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -141,6 +141,11 @@ public actor GatewayNodeSession { private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] private var pluginSurfaceUrls: [String: String] = [:] + private struct PluginSurfaceRefreshResponse: Decodable { + let pluginSurfaceUrls: [String: AnyCodable]? + let canvasHostUrl: String? + } + public init() {} private func connectOptionsKey(_ options: GatewayConnectOptions) -> String { @@ -260,6 +265,36 @@ public actor GatewayNodeSession { self.pluginSurfaceUrls["canvas"] } + @discardableResult + public func refreshPluginSurfaceUrl(surface: String, timeoutSeconds: Int = 8) async -> String? { + guard let channel = self.channel else { return nil } + let trimmedSurface = surface.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSurface.isEmpty else { return nil } + + if let refreshed = await self.requestPluginSurfaceRefresh( + channel: channel, + method: "node.pluginSurface.refresh", + params: ["surface": AnyCodable(trimmedSurface)], + surface: trimmedSurface, + timeoutSeconds: timeoutSeconds) + { + return refreshed + } + + guard trimmedSurface == "canvas" else { return nil } + return await self.requestPluginSurfaceRefresh( + channel: channel, + method: "node.canvas.capability.refresh", + params: nil, + surface: trimmedSurface, + timeoutSeconds: timeoutSeconds) + } + + @discardableResult + public func refreshCanvasHostUrl(timeoutSeconds: Int = 8) async -> String? { + await self.refreshPluginSurfaceUrl(surface: "canvas", timeoutSeconds: timeoutSeconds) + } + public func currentRemoteAddress() -> String? { guard let url = self.activeURL else { return nil } guard let host = url.host else { return url.absoluteString } @@ -311,7 +346,9 @@ public actor GatewayNodeSession { private func handlePush(_ push: GatewayPush) async { switch push { case let .snapshot(ok): - self.pluginSurfaceUrls = self.normalizePluginSurfaceUrls(ok.pluginsurfaceurls) + self.pluginSurfaceUrls = self.normalizePluginSurfaceUrls( + ok.pluginsurfaceurls, + legacyCanvasHostUrl: ok.canvashosturl) if self.hasEverConnected { self.broadcastServerEvent( EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil)) @@ -383,9 +420,47 @@ public actor GatewayNodeSession { } private func normalizePluginSurfaceUrls(_ raw: [String: AnyCodable]?) -> [String: String] { - guard let raw else { return [:] } - return raw.compactMapValues { value in - self.normalizeCanvasHostUrl(value.value as? String) + self.normalizePluginSurfaceUrls(raw, legacyCanvasHostUrl: nil) + } + + private func normalizePluginSurfaceUrls( + _ raw: [String: AnyCodable]?, + legacyCanvasHostUrl: String?) -> [String: String] + { + var normalized: [String: String] = [:] + if let raw { + normalized = raw.compactMapValues { value in + self.normalizeCanvasHostUrl(value.value as? String) + } + } + if normalized["canvas"] == nil, let legacy = self.normalizeCanvasHostUrl(legacyCanvasHostUrl) { + normalized["canvas"] = legacy + } + return normalized + } + + private func requestPluginSurfaceRefresh( + channel: GatewayChannelActor, + method: String, + params: [String: AnyCodable]?, + surface: String, + timeoutSeconds: Int) async -> String? + { + do { + let data = try await channel.request( + method: method, + params: params, + timeoutMs: Double(timeoutSeconds * 1000)) + let decoded = try self.decoder.decode(PluginSurfaceRefreshResponse.self, from: data) + let urls = self.normalizePluginSurfaceUrls( + decoded.pluginSurfaceUrls, + legacyCanvasHostUrl: decoded.canvasHostUrl) + guard let refreshed = urls[surface] else { return nil } + self.pluginSurfaceUrls[surface] = refreshed + return refreshed + } catch { + self.logger.debug("\(method, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + return nil } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index f94e6688bab..72e33d7d965 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -99,6 +99,7 @@ public struct HelloOk: Codable, Sendable { public let features: [String: AnyCodable] public let snapshot: Snapshot public let pluginsurfaceurls: [String: AnyCodable]? + public let canvashosturl: String? public let auth: [String: AnyCodable] public let policy: [String: AnyCodable] @@ -109,6 +110,7 @@ public struct HelloOk: Codable, Sendable { features: [String: AnyCodable], snapshot: Snapshot, pluginsurfaceurls: [String: AnyCodable]?, + canvashosturl: String?, auth: [String: AnyCodable], policy: [String: AnyCodable]) { @@ -118,6 +120,7 @@ public struct HelloOk: Codable, Sendable { self.features = features self.snapshot = snapshot self.pluginsurfaceurls = pluginsurfaceurls + self.canvashosturl = canvashosturl self.auth = auth self.policy = policy } @@ -129,6 +132,7 @@ public struct HelloOk: Codable, Sendable { case features case snapshot case pluginsurfaceurls = "pluginSurfaceUrls" + case canvashosturl = "canvasHostUrl" case auth case policy } diff --git a/docs/gateway/bridge-protocol.md b/docs/gateway/bridge-protocol.md index 2c5d1b20ef1..3a76407608c 100644 --- a/docs/gateway/bridge-protocol.md +++ b/docs/gateway/bridge-protocol.md @@ -41,7 +41,9 @@ authoritative pin without explicit user intent or other out-of-band verification 4. Gateway waits for approval, then sends `pair-ok` and `hello-ok`. Historically, `hello-ok` returned `serverName`; hosted plugin surfaces are now -advertised through `pluginSurfaceUrls`. +advertised through `pluginSurfaceUrls`. `canvasHostUrl` is still emitted as a +deprecated alias for `pluginSurfaceUrls.canvas` so older native clients keep +working. ## Frames diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 39dacea3846..060d288043d 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -106,7 +106,13 @@ handshake failure. `server`, `features`, `snapshot`, and `policy` are all required by the schema (`src/gateway/protocol/schema/frames.ts`). `auth` is also required and reports the negotiated role/scopes. `pluginSurfaceUrls` is optional and maps plugin -surface names, such as `canvas`, to scoped hosted URLs. +surface names, such as `canvas`, to scoped hosted URLs. `canvasHostUrl` is a +deprecated compatibility alias for `pluginSurfaceUrls.canvas`. + +Scoped plugin surface URLs may expire. Nodes can call +`node.pluginSurface.refresh` with `{ "surface": "canvas" }` to receive a fresh +entry in `pluginSurfaceUrls`; `node.canvas.capability.refresh` remains as a +deprecated Canvas-only alias for older native clients. When no device token is issued, `hello-ok.auth` reports the negotiated permissions without token fields: diff --git a/src/gateway/plugin-node-capability.test.ts b/src/gateway/plugin-node-capability.test.ts index 205fa2649bb..8bd9e4d556b 100644 --- a/src/gateway/plugin-node-capability.test.ts +++ b/src/gateway/plugin-node-capability.test.ts @@ -3,6 +3,8 @@ import { buildPluginNodeCapabilityScopedHostUrl, hasAuthorizedPluginNodeCapability, normalizePluginNodeCapabilityScopedUrl, + refreshClientPluginNodeCapability, + replacePluginNodeCapabilityInScopedHostUrl, setClientPluginNodeCapability, } from "./plugin-node-capability.js"; import type { GatewayWsClient } from "./server/ws-types.js"; @@ -51,6 +53,15 @@ describe("plugin node capability helpers", () => { }); }); + test("replaces scoped capability tokens without nesting capability prefixes", () => { + expect( + replacePluginNodeCapabilityInScopedHostUrl( + "http://127.0.0.1:18789/__openclaw__/cap/old-token/__openclaw__/a2ui/", + "new token", + ), + ).toBe("http://127.0.0.1:18789/__openclaw__/cap/new%20token/__openclaw__/a2ui"); + }); + test("marks malformed scoped urls without authorizing a path capability", () => { const normalized = normalizePluginNodeCapabilityScopedUrl("/__openclaw__/cap/broken"); expect(normalized.scopedPath).toBe(true); @@ -79,6 +90,29 @@ describe("plugin node capability helpers", () => { }); }); + test("refreshes client plugin surface url and stored capability", () => { + const client = makeClient({ + pluginSurfaceUrls: { + canvas: "http://127.0.0.1:18789/__openclaw__/cap/old-token", + }, + }); + const refreshed = refreshClientPluginNodeCapability({ + client, + surface: { surface: "canvas", ttlMs: 100 }, + nowMs: 1_000, + }); + expect(refreshed?.surface).toBe("canvas"); + expect(refreshed?.expiresAtMs).toBe(1_100); + expect(refreshed?.capability).toEqual(expect.any(String)); + expect(refreshed?.scopedUrl).toContain("/__openclaw__/cap/"); + expect(refreshed?.scopedUrl).not.toContain("old-token/__openclaw__/cap/"); + expect(client.pluginSurfaceUrls?.canvas).toBe(refreshed?.scopedUrl); + expect(client.pluginNodeCapabilities?.canvas).toEqual({ + capability: refreshed?.capability, + expiresAtMs: 1_100, + }); + }); + test("authorizes matching plugin surface capabilities and slides expiry", () => { const client = makeClient({ pluginNodeCapabilities: { diff --git a/src/gateway/plugin-node-capability.ts b/src/gateway/plugin-node-capability.ts index 7f17554ace1..e6fda606dc4 100644 --- a/src/gateway/plugin-node-capability.ts +++ b/src/gateway/plugin-node-capability.ts @@ -12,6 +12,7 @@ export type PluginNodeCapabilitySurface = { }; export type PluginNodeCapabilityClient = { + pluginSurfaceUrls?: Record; pluginNodeCapabilities?: Record; }; @@ -62,6 +63,39 @@ export function buildPluginNodeCapabilityScopedHostUrl( } } +export function replacePluginNodeCapabilityInScopedHostUrl( + scopedUrl: string, + capability: string, +): string | undefined { + const normalizedCapability = normalizeCapability(capability); + if (!normalizedCapability) { + return undefined; + } + try { + const url = new URL(scopedUrl); + const prefix = `${PLUGIN_NODE_CAPABILITY_PATH_PREFIX}/`; + const markerStart = url.pathname.indexOf(prefix); + if (markerStart < 0) { + return buildPluginNodeCapabilityScopedHostUrl(scopedUrl, normalizedCapability); + } + const capabilityStart = markerStart + prefix.length; + const nextSlashIndex = url.pathname.indexOf("/", capabilityStart); + const capabilityEnd = nextSlashIndex >= 0 ? nextSlashIndex : url.pathname.length; + if (capabilityEnd <= capabilityStart) { + return undefined; + } + url.pathname = + url.pathname.slice(0, capabilityStart) + + encodeURIComponent(normalizedCapability) + + url.pathname.slice(capabilityEnd); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return undefined; + } +} + export function normalizePluginNodeCapabilityScopedUrl( rawUrl: string, ): NormalizedPluginNodeCapabilityUrl { @@ -129,6 +163,49 @@ export function setClientPluginNodeCapability(params: { }; } +export function refreshClientPluginNodeCapability(params: { + client: PluginNodeCapabilityClient; + surface: PluginNodeCapabilitySurface; + nowMs?: number; +}): + | { + surface: string; + capability: string; + expiresAtMs: number; + scopedUrl: string; + } + | undefined { + const surface = normalizeSurface(params.surface.surface); + if (!surface) { + return undefined; + } + const existingUrl = params.client.pluginSurfaceUrls?.[surface]; + if (!existingUrl) { + return undefined; + } + const capability = mintPluginNodeCapabilityToken(); + const nowMs = params.nowMs ?? Date.now(); + const expiresAtMs = nowMs + resolvePluginNodeCapabilityTtlMs(params.surface); + const scopedUrl = replacePluginNodeCapabilityInScopedHostUrl(existingUrl, capability); + if (!scopedUrl) { + return undefined; + } + params.client.pluginSurfaceUrls ??= {}; + params.client.pluginSurfaceUrls[surface] = scopedUrl; + setClientPluginNodeCapability({ + client: params.client, + surface: params.surface, + capability, + expiresAtMs, + }); + return { + surface, + capability, + expiresAtMs, + scopedUrl, + }; +} + export function hasAuthorizedPluginNodeCapability(params: { clients: Set; surface: PluginNodeCapabilitySurface; diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index 79af9050c8c..1433c7e0f18 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -89,6 +89,7 @@ export const HelloOkSchema = Type.Object( ), snapshot: SnapshotSchema, pluginSurfaceUrls: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)), + canvasHostUrl: Type.Optional(NonEmptyString), auth: Type.Object( { deviceToken: Type.Optional(NonEmptyString), diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 7094fa06abf..281a0a8ab85 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -255,6 +255,76 @@ async function ackPending(nodeId: string, ids: string[], commands?: string[]) { return respond; } +describe("node plugin surface refresh", () => { + it("refreshes generic plugin surface capability urls", async () => { + const respond = vi.fn(); + const client = { + connect: { + client: { id: "node-1", mode: "node" }, + }, + pluginSurfaceUrls: { + canvas: "http://127.0.0.1:18789/__openclaw__/cap/old-token", + }, + }; + + await nodeHandlers["node.pluginSurface.refresh"]({ + req: { type: "req", id: "r1", method: "node.pluginSurface.refresh", params: {} }, + params: { surface: "canvas" }, + client: client as never, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + + expect(respond).toHaveBeenCalledWith( + true, + { + surface: "canvas", + pluginSurfaceUrls: { + canvas: expect.stringContaining("/__openclaw__/cap/"), + }, + expiresAtMs: expect.any(Number), + }, + undefined, + ); + expect(client.pluginSurfaceUrls.canvas).not.toContain("old-token"); + }); + + it("keeps legacy canvas capability refresh as a compatibility alias", async () => { + const respond = vi.fn(); + const client = { + connect: { + client: { id: "node-1", mode: "node" }, + }, + pluginSurfaceUrls: { + canvas: "http://127.0.0.1:18789/__openclaw__/cap/old-token", + }, + }; + + await nodeHandlers["node.canvas.capability.refresh"]({ + req: { type: "req", id: "r1", method: "node.canvas.capability.refresh", params: {} }, + params: {}, + client: client as never, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + + expect(respond).toHaveBeenCalledWith( + true, + { + canvasCapability: expect.any(String), + canvasCapabilityExpiresAtMs: expect.any(Number), + canvasHostUrl: expect.stringContaining("/__openclaw__/cap/"), + pluginSurfaceUrls: { + canvas: expect.stringContaining("/__openclaw__/cap/"), + }, + }, + undefined, + ); + }); +}); + describe("node.invoke APNs wake path", () => { beforeEach(() => { mocks.getRuntimeConfig.mockClear(); diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index e79e8117978..cb4acf8b97d 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -33,6 +33,7 @@ import { } from "../node-command-policy.js"; import { applyPluginNodeInvokePolicy } from "../node-invoke-plugin-policy.js"; import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js"; +import { refreshClientPluginNodeCapability } from "../plugin-node-capability.js"; import { type ConnectParams, ErrorCodes, @@ -65,7 +66,7 @@ import { respondUnavailableOnThrow, safeParseJson, } from "./nodes.helpers.js"; -import type { GatewayRequestContext } from "./shared-types.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./shared-types.js"; import type { GatewayRequestHandlers } from "./types.js"; export { @@ -139,6 +140,55 @@ function isForbiddenBrowserProxyMutation(params: unknown): boolean { return Boolean(method && path && isPersistentBrowserProxyMutation(method, path)); } +function normalizePluginSurfaceRefreshParams(params: unknown): { surface: string } | undefined { + if (!params || typeof params !== "object") { + return undefined; + } + const surface = normalizeOptionalString((params as { surface?: unknown }).surface); + if (!surface) { + return undefined; + } + return { surface }; +} + +function respondRefreshedPluginSurface(params: { + surface: string; + client: GatewayClient | null; + respond: RespondFn; + legacyCanvasPayload?: boolean; +}) { + const refreshed = params.client + ? refreshClientPluginNodeCapability({ + client: params.client, + surface: { surface: params.surface }, + }) + : undefined; + if (!refreshed) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `${params.surface} plugin surface unavailable`), + ); + return; + } + params.respond( + true, + params.legacyCanvasPayload + ? { + canvasCapability: refreshed.capability, + canvasCapabilityExpiresAtMs: refreshed.expiresAtMs, + canvasHostUrl: refreshed.scopedUrl, + pluginSurfaceUrls: { [refreshed.surface]: refreshed.scopedUrl }, + } + : { + surface: refreshed.surface, + pluginSurfaceUrls: { [refreshed.surface]: refreshed.scopedUrl }, + expiresAtMs: refreshed.expiresAtMs, + }, + undefined, + ); +} + async function resolveDirectNodePushConfig() { const auth = await resolveApnsAuthConfigFromEnv(process.env); return auth.ok @@ -842,6 +892,26 @@ export const nodeHandlers: GatewayRequestHandlers = { respond(true, { ts: Date.now(), ...node }, undefined); }); }, + "node.pluginSurface.refresh": async ({ params, respond, client }) => { + const parsed = normalizePluginSurfaceRefreshParams(params); + if (!parsed) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "surface required")); + return; + } + respondRefreshedPluginSurface({ + surface: parsed.surface, + client, + respond, + }); + }, + "node.canvas.capability.refresh": async ({ respond, client }) => { + respondRefreshedPluginSurface({ + surface: "canvas", + client, + respond, + legacyCanvasPayload: true, + }); + }, "node.pending.pull": async ({ params, respond, client, context }) => { if (!validateNodeListParams(params)) { respondInvalidParams({ diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index df8b4f9b097..ad0a3688c5d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1338,6 +1338,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar }); } } + const canvasHostUrl = pluginSurfaceUrls.canvas; const usesSharedGatewayAuth = authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy"; const sharedGatewaySessionGeneration = usesSharedGatewayAuth @@ -1486,6 +1487,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar features: { methods: gatewayMethods, events }, snapshot, ...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}), + ...(canvasHostUrl ? { canvasHostUrl } : {}), auth: { role, scopes: helloOkAuthScopes,