mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-07 07:58:36 +00:00
fix: refresh canvas plugin surface urls
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -239,6 +239,7 @@ class NodeRuntime(
|
||||
_canvasRehydrateErrorText.value = null
|
||||
},
|
||||
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
|
||||
refreshCanvasHostUrl = { nodeSession.refreshCanvasHostUrl() },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
)
|
||||
|
||||
@@ -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<Unit>()
|
||||
private val closedDeferred = CompletableDeferred<Unit>()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -39,5 +39,4 @@ class GatewaySessionInvokeTimeoutTest {
|
||||
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(121_000L))
|
||||
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -288,6 +288,7 @@ class InvokeDispatcherTest {
|
||||
debugBuild = { debugBuild },
|
||||
onCanvasA2uiPush = {},
|
||||
onCanvasA2uiReset = {},
|
||||
refreshCanvasHostUrl = { null },
|
||||
motionActivityAvailable = { motionActivityAvailable },
|
||||
motionPedometerAvailable = { motionPedometerAvailable },
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -111,6 +111,12 @@ actor GatewayConnection {
|
||||
|
||||
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
|
||||
private var lastSnapshot: HelloOk?
|
||||
private var canvasPluginSurfaceUrlOverride: String?
|
||||
|
||||
private struct PluginSurfaceRefreshResponse: Decodable {
|
||||
let pluginSurfaceUrls: [String: AnyCodable]?
|
||||
let canvasHostUrl: String?
|
||||
}
|
||||
|
||||
private struct LossyDecodable<Value: Decodable>: 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ struct MacGatewayChatTransportMappingTests {
|
||||
features: [:],
|
||||
snapshot: snapshot,
|
||||
pluginsurfaceurls: nil,
|
||||
canvashosturl: nil,
|
||||
auth: [:],
|
||||
policy: [:])
|
||||
|
||||
|
||||
@@ -141,6 +141,11 @@ public actor GatewayNodeSession {
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type PluginNodeCapabilitySurface = {
|
||||
};
|
||||
|
||||
export type PluginNodeCapabilityClient = {
|
||||
pluginSurfaceUrls?: Record<string, string>;
|
||||
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
|
||||
};
|
||||
|
||||
@@ -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<GatewayWsClient>;
|
||||
surface: PluginNodeCapabilitySurface;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user