iOS: auto-load the scoped gateway canvas with safe fallback (#40282)

Merged via squash.

- mb-server validation: `swift test --package-path apps/shared/OpenClawKit --filter GatewayNodeSessionTests`
- mb-server validation: `pnpm build`
- Scope note: top-level `RootTabs` shell change was intentionally removed from this PR before merge
This commit is contained in:
Mariano
2026-03-08 22:47:39 +01:00
committed by GitHub
parent e806c479f5
commit abb8f63107
4 changed files with 185 additions and 28 deletions

View File

@@ -11,6 +11,50 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
var idempotencyKey: String?
}
private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capability: String) -> String? {
let marker = "/__openclaw__/cap/"
guard let markerRange = scopedUrl.range(of: marker) else { return nil }
let capabilityStart = markerRange.upperBound
let suffix = scopedUrl[capabilityStart...]
let nextSlash = suffix.firstIndex(of: "/")
let nextQuery = suffix.firstIndex(of: "?")
let nextFragment = suffix.firstIndex(of: "#")
let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap { $0 }.min() ?? scopedUrl.endIndex
guard capabilityStart < capabilityEnd else { return nil }
return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
}
func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
guard var parsed = URLComponents(string: trimmed) else { return trimmed }
let parsedHost = parsed.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedIsLoopback = !parsedHost.isEmpty && LoopbackHost.isLoopback(parsedHost)
if !parsedHost.isEmpty, !parsedIsLoopback {
guard let activeURL else { return trimmed }
let isTLS = activeURL.scheme?.lowercased() == "wss"
guard isTLS else { return trimmed }
parsed.scheme = "https"
if parsed.port == nil {
let tlsPort = activeURL.port ?? 443
parsed.port = (tlsPort == 443) ? nil : tlsPort
}
return parsed.string ?? trimmed
}
guard let activeURL, let fallbackHost = activeURL.host, !LoopbackHost.isLoopback(fallbackHost) else {
return trimmed
}
let isTLS = activeURL.scheme?.lowercased() == "wss"
parsed.scheme = isTLS ? "https" : "http"
parsed.host = fallbackHost
let fallbackPort = activeURL.port ?? (isTLS ? 443 : 80)
parsed.port = ((isTLS && fallbackPort == 443) || (!isTLS && fallbackPort == 80)) ? nil : fallbackPort
return parsed.string ?? trimmed
}
public actor GatewayNodeSession {
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
@@ -223,6 +267,46 @@ public actor GatewayNodeSession {
self.canvasHostUrl
}
public func refreshNodeCanvasCapability(timeoutMs: Int = 8_000) async -> Bool {
guard let channel = self.channel else { return false }
do {
let data = try await channel.request(
method: "node.canvas.capability.refresh",
params: [:],
timeoutMs: Double(max(timeoutMs, 1)))
guard
let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let rawCapability = payload["canvasCapability"] as? String
else {
self.logger.warning("node.canvas.capability.refresh missing canvasCapability")
return false
}
let capability = rawCapability.trimmingCharacters(in: .whitespacesAndNewlines)
guard !capability.isEmpty else {
self.logger.warning("node.canvas.capability.refresh returned empty capability")
return false
}
let scopedUrl = self.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !scopedUrl.isEmpty else {
self.logger.warning("node.canvas.capability.refresh missing local canvasHostUrl")
return false
}
guard let refreshed = replaceCanvasCapabilityInScopedHostUrl(
scopedUrl: scopedUrl,
capability: capability)
else {
self.logger.warning("node.canvas.capability.refresh could not rewrite scoped canvas URL")
return false
}
self.canvasHostUrl = refreshed
return true
} catch {
self.logger.warning(
"node.canvas.capability.refresh failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
public func currentRemoteAddress() -> String? {
guard let url = self.activeURL else { return nil }
guard let host = url.host else { return url.absoluteString }
@@ -275,7 +359,7 @@ public actor GatewayNodeSession {
switch push {
case let .snapshot(ok):
let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines)
self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil
self.canvasHostUrl = self.normalizeCanvasHostUrl(raw)
if self.hasEverConnected {
self.broadcastServerEvent(
EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil))
@@ -342,6 +426,10 @@ public actor GatewayNodeSession {
await self.onConnected?()
}
private func normalizeCanvasHostUrl(_ raw: String?) -> String? {
canonicalizeCanvasHostUrl(raw: raw, activeURL: self.activeURL)
}
private func handleEvent(_ evt: EventFrame) async {
self.broadcastServerEvent(evt)
guard evt.event == "node.invoke.request" else { return }
@@ -350,16 +438,21 @@ public actor GatewayNodeSession {
do {
let request = try self.decodeInvokeRequest(from: payload)
let timeoutLabel = request.timeoutMs.map(String.init) ?? "none"
self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)")
self.logger.info(
"node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)")
guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let req = BridgeInvokeRequest(
id: request.id,
command: request.command,
paramsJSON: request.paramsJSON)
self.logger.info("node invoke executing id=\(request.id, privacy: .public)")
let response = await Self.invokeWithTimeout(
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke
)
self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
self.logger.info(
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
await self.sendInvokeResult(request: request, response: response)
} catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
@@ -380,7 +473,8 @@ public actor GatewayNodeSession {
private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async {
guard let channel = self.channel else { return }
self.logger.info("node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
self.logger.info(
"node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
var params: [String: AnyCodable] = [
"id": AnyCodable(request.id),
"nodeId": AnyCodable(request.nodeId),
@@ -398,7 +492,8 @@ public actor GatewayNodeSession {
do {
try await channel.send(method: "node.invoke.result", params: params)
} catch {
self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
self.logger.error(
"node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
}
}

View File

@@ -169,6 +169,24 @@ private actor SeqGapProbe {
}
struct GatewayNodeSessionTests {
@Test
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com")!)
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
}
@Test
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
let normalized = canonicalizeCanvasHostUrl(
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com:7443")!)
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
}
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)