fix: refresh canvas plugin surface urls

This commit is contained in:
Peter Steinberger
2026-05-07 00:50:58 +01:00
parent be5ecd5ec0
commit 0deca824c9
21 changed files with 502 additions and 32 deletions

View File

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

View File

@@ -239,6 +239,7 @@ class NodeRuntime(
_canvasRehydrateErrorText.value = null
},
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
refreshCanvasHostUrl = { nodeSession.refreshCanvasHostUrl() },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
)

View File

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

View File

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

View File

@@ -39,5 +39,4 @@ class GatewaySessionInvokeTimeoutTest {
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(121_000L))
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(Long.MAX_VALUE))
}
}

View File

@@ -288,6 +288,7 @@ class InvokeDispatcherTest {
debugBuild = { debugBuild },
onCanvasA2uiPush = {},
onCanvasA2uiReset = {},
refreshCanvasHostUrl = { null },
motionActivityAvailable = { motionActivityAvailable },
motionPedometerAvailable = { motionPedometerAvailable },
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ struct MacGatewayChatTransportMappingTests {
features: [:],
snapshot: snapshot,
pluginsurfaceurls: nil,
canvashosturl: nil,
auth: [:],
policy: [:])

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

@@ -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({

View File

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