import Foundation import OpenClawProtocol import Testing @testable import OpenClawKit extension NSLock { fileprivate func withLock(_ body: () -> T) -> T { lock() defer { self.unlock() } return body() } } private final class InvokeCancellationFlag: @unchecked Sendable { private let lock = NSLock() private var cancelled = false func markCancelled() { self.lock.withLock { self.cancelled = true } } func isCancelled() -> Bool { self.lock.withLock { self.cancelled } } } private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecked Sendable { private let callbacks: [Error?] init(callbacks: [Error?]) { self.callbacks = callbacks } var state: URLSessionTask.State { .running } func resume() {} func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { _ = (closeCode, reason) } func send(_ message: URLSessionWebSocketTask.Message) async throws { _ = message } func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { for callback in self.callbacks { pongReceiveHandler(callback) } } func receive() async throws -> URLSessionWebSocketTask.Message { throw URLError(.badServerResponse) } func receive( completionHandler: @escaping @Sendable (Result) -> Void) { completionHandler(.failure(URLError(.badServerResponse))) } } private final class FirstCancelGate: @unchecked Sendable { private let condition = NSCondition() private var shouldBlock = true private var started = false private var released = false func blockIfNeeded() { self.condition.lock() guard self.shouldBlock else { self.condition.unlock() return } self.shouldBlock = false self.started = true self.condition.broadcast() while !self.released { self.condition.wait() } self.condition.unlock() } func hasStarted() -> Bool { self.condition.lock() defer { self.condition.unlock() } return self.started } func release() { self.condition.lock() self.released = true self.condition.broadcast() self.condition.unlock() } } private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable { private let lock = NSLock() private let helloAuth: [String: Any]? private let helloMethods: [String] private let connectError: [String: Any]? private let cancelGate: FirstCancelGate? private var _state: URLSessionTask.State = .suspended private var connectRequestId: String? private var connectAuth: [String: Any]? private var connectDevice: [String: Any]? private var sentRequestMethods: [String] = [] private var sentRequestPayloads: [[String: Any]] = [] private var receivePhase = 0 private var pendingReceiveHandler: (@Sendable (Result) -> Void)? init( helloAuth: [String: Any]? = nil, helloMethods: [String] = [], connectError: [String: Any]? = nil, cancelGate: FirstCancelGate? = nil) { self.helloAuth = helloAuth self.helloMethods = helloMethods self.connectError = connectError self.cancelGate = cancelGate } var state: URLSessionTask.State { get { self.lock.withLock { self._state } } set { self.lock.withLock { self._state = newValue } } } func resume() { self.state = .running } func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { _ = (closeCode, reason) self.state = .canceling self.cancelGate?.blockIfNeeded() let handler = self.lock.withLock { () -> (@Sendable (Result< URLSessionWebSocketTask.Message, Error, >) -> Void)? in defer { self.pendingReceiveHandler = nil } return self.pendingReceiveHandler } handler?(Result.failure(URLError(.cancelled))) } func send(_ message: URLSessionWebSocketTask.Message) async throws { let data: Data? = switch message { case let .data(d): d case let .string(s): s.data(using: .utf8) @unknown default: nil } guard let data else { return } if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], obj["type"] as? String == "req", let method = obj["method"] as? String { self.lock.withLock { self.sentRequestMethods.append(method) self.sentRequestPayloads.append(obj) } guard method == "connect", let id = obj["id"] as? String else { return } let params = obj["params"] as? [String: Any] let auth = (params?["auth"] as? [String: Any]) ?? [:] let device = params?["device"] as? [String: Any] self.lock.withLock { self.connectRequestId = id self.connectAuth = auth self.connectDevice = device } } } func latestConnectAuth() -> [String: Any]? { self.lock.withLock { self.connectAuth } } func latestConnectDevice() -> [String: Any]? { self.lock.withLock { self.connectDevice } } func sentRequestCount(method: String) -> Int { self.lock.withLock { self.sentRequestMethods.count(where: { $0 == method }) } } func sentRequests(method: String) -> [[String: Any]] { self.lock.withLock { self.sentRequestPayloads.filter { $0["method"] as? String == method } } } func hasPendingReceiveHandler() -> Bool { self.lock.withLock { self.pendingReceiveHandler != nil } } func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { pongReceiveHandler(nil) } func receive() async throws -> URLSessionWebSocketTask.Message { let phase = self.lock.withLock { () -> Int in let current = self.receivePhase self.receivePhase += 1 return current } if phase == 0 { return .data(Self.connectChallengeData(nonce: "nonce-1")) } for _ in 0..<50 { let id = self.lock.withLock { self.connectRequestId } if let id { if let connectError { return .data(Self.connectErrorData(id: id, error: connectError)) } return .data(Self.connectOkData( id: id, auth: self.helloAuth, methods: self.helloMethods)) } try await Task.sleep(nanoseconds: 1_000_000) } if let connectError { return .data(Self.connectErrorData(id: "connect", error: connectError)) } return .data(Self.connectOkData( id: "connect", auth: self.helloAuth, methods: self.helloMethods)) } func receive( completionHandler: @escaping @Sendable (Result) -> Void) { self.lock.withLock { self.pendingReceiveHandler = completionHandler } } func emitReceiveFailure() { let handler = self.lock.withLock { () -> (@Sendable (Result< URLSessionWebSocketTask.Message, Error, >) -> Void)? in self._state = .canceling defer { self.pendingReceiveHandler = nil } return self.pendingReceiveHandler } handler?(Result.failure(URLError(.networkConnectionLost))) } func emitInvokeRequest(id: String, command: String, idempotencyKey: String? = nil) { self.emitInvokeRequest( id: id, command: command, paramsJSON: "{}", idempotencyKey: idempotencyKey) } func emitInvokeRequest( id: String, command: String, paramsJSON: String?, idempotencyKey: String? = nil) { let handler = self.lock.withLock { () -> (@Sendable (Result< URLSessionWebSocketTask.Message, Error, >) -> Void)? in defer { self.pendingReceiveHandler = nil } return self.pendingReceiveHandler } handler?(.success(.data(Self.invokeRequestData( id: id, command: command, paramsJSON: paramsJSON, idempotencyKey: idempotencyKey)))) } private static func connectChallengeData(nonce: String) -> Data { let frame: [String: Any] = [ "type": "event", "event": "connect.challenge", "payload": ["nonce": nonce], ] return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() } private static func connectOkData( id: String, auth: [String: Any]? = nil, methods: [String] = []) -> Data { var payload: [String: Any] = [ "type": "hello-ok", "protocol": 2, "server": [ "version": "test", "connId": "test", ], "features": [ "methods": methods, "events": [], ], "snapshot": [ "presence": [["ts": 1]], "health": [:], "stateVersion": [ "presence": 0, "health": 0, ], "uptimeMs": 0, ], "policy": [ "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000, ], "auth": [:], ] if let auth { payload["auth"] = auth } let frame: [String: Any] = [ "type": "res", "id": id, "ok": true, "payload": payload, ] return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() } private static func connectErrorData(id: String, error: [String: Any]) -> Data { let frame: [String: Any] = [ "type": "res", "id": id, "ok": false, "error": error, ] return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() } private static func invokeRequestData( id: String, command: String, paramsJSON: String?, idempotencyKey: String?) -> Data { var payload: [String: Any] = [ "id": id, "nodeId": "test-node", "command": command, "paramsJSON": paramsJSON ?? NSNull(), ] if let idempotencyKey { payload["idempotencyKey"] = idempotencyKey } let frame: [String: Any] = [ "type": "event", "event": "node.invoke.request", "payload": payload, ] return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() } } private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable { private let lock = NSLock() private let helloAuth: [String: Any]? private let helloMethods: [String] private let connectError: [String: Any]? private let cancelGate: FirstCancelGate? private var tasks: [FakeGatewayWebSocketTask] = [] private var requests: [URLRequest] = [] private var makeCount = 0 init( helloAuth: [String: Any]? = nil, helloMethods: [String] = [], connectError: [String: Any]? = nil, cancelGate: FirstCancelGate? = nil) { self.helloAuth = helloAuth self.helloMethods = helloMethods self.connectError = connectError self.cancelGate = cancelGate } func snapshotMakeCount() -> Int { self.lock.withLock { self.makeCount } } func latestTask() -> FakeGatewayWebSocketTask? { self.lock.withLock { self.tasks.last } } func latestRequest() -> URLRequest? { self.lock.withLock { self.requests.last } } func makeWebSocketTask(url: URL) -> WebSocketTaskBox { self.makeWebSocketTask(request: URLRequest(url: url)) } func makeWebSocketTask(request: URLRequest) -> WebSocketTaskBox { self.lock.withLock { self.makeCount += 1 self.requests.append(request) let task = FakeGatewayWebSocketTask( helloAuth: self.helloAuth, helloMethods: self.helloMethods, connectError: self.connectError, cancelGate: self.cancelGate) self.tasks.append(task) return WebSocketTaskBox(task: task) } } } private final class MutableHeaderValue: @unchecked Sendable { private let lock = NSLock() private var value: String private var reads = 0 init(value: String) { self.value = value } func get() -> String { self.lock.withLock { self.reads += 1 return self.value } } func set(_ value: String) { self.lock.withLock { self.value = value } } func readCount() -> Int { self.lock.withLock { self.reads } } } private actor SeqGapProbe { private var saw = false func mark() { self.saw = true } func value() -> Bool { self.saw } } private actor DisconnectProbe { private var reasons: [String] = [] func record(_ reason: String) { self.reasons.append(reason) } func values() -> [String] { self.reasons } } private actor AsyncGate { private var started = false private var released = false private var waiters: [CheckedContinuation] = [] func wait() async { self.started = true guard !self.released else { return } await withCheckedContinuation { continuation in self.waiters.append(continuation) } } func hasStarted() -> Bool { self.started } func release() { self.released = true let waiters = self.waiters self.waiters.removeAll() for waiter in waiters { waiter.resume() } } } private actor ComputerInvokeProbe { private var invocationCount = 0 private var released = false private var releaseWaiters: [CheckedContinuation] = [] func execute(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse { self.invocationCount += 1 await withCheckedContinuation { continuation in if self.released { continuation.resume() } else { self.releaseWaiters.append(continuation) } } return BridgeInvokeResponse( id: request.id, ok: true, payloadJSON: #"{"acted":true}"#) } func count() -> Int { self.invocationCount } func release() { self.released = true let waiters = self.releaseWaiters self.releaseWaiters.removeAll() for waiter in waiters { waiter.resume() } } } private func nodeInvokePush(id: String, command: String) -> GatewayPush { .event(EventFrame( type: "event", event: "node.invoke.request", payload: AnyCodable([ "id": AnyCodable(id), "nodeId": AnyCodable("test-node"), "command": AnyCodable(command), "paramsJSON": AnyCodable("{}"), ]), seq: nil, stateversion: nil)) } @Suite(.serialized) struct GatewayNodeSessionTests { @Test func `watch approval warning text is optional and round trips`() throws { let legacy = try JSONDecoder().decode( OpenClawWatchExecApprovalItem.self, from: Data(#"{"id":"approval","commandText":"echo ok","allowedDecisions":["deny"]}"#.utf8)) #expect(legacy.warningText == nil) var current = legacy current.warningText = "Review shell expansion" let decoded = try JSONDecoder().decode( OpenClawWatchExecApprovalItem.self, from: JSONEncoder().encode(current)) #expect(decoded.warningText == "Review shell expansion") } @Test func `watch approval recovery schema carries exact resolution attempt identifiers`() throws { let resetAttemptID = "\u{0085}reset-attempt\u{0085}" let prompt = OpenClawWatchExecApprovalPromptMessage( approval: OpenClawWatchExecApprovalItem( id: "approval", commandText: "echo ok"), resetResolutionAttemptId: resetAttemptID) let promptData = try JSONEncoder().encode(prompt) let promptObject = try #require( JSONSerialization.jsonObject(with: promptData) as? [String: Any]) #expect(try Array(#require(promptObject["resetResolutionAttemptId"] as? String).utf8) == Array(resetAttemptID.utf8)) #expect(promptObject["deliveryId"] == nil) #expect(promptObject["resetResolvingState"] == nil) let approvalID = "\u{0085}held-approval\u{0085}" let activeAttemptID = "\u{0085}active-attempt\u{0085}" let request = OpenClawWatchExecApprovalSnapshotRequestMessage( requestId: "request", heldApprovals: [OpenClawWatchExecApprovalSnapshotRequestItem( approvalId: approvalID, activeResolutionAttemptId: activeAttemptID)]) let decoded = try JSONDecoder().decode( OpenClawWatchExecApprovalSnapshotRequestMessage.self, from: JSONEncoder().encode(request)) #expect(decoded.heldApprovals.count == 1) #expect(Array(decoded.heldApprovals[0].approvalId.utf8) == Array(approvalID.utf8)) #expect(try Array(#require(decoded.heldApprovals[0].activeResolutionAttemptId).utf8) == Array(activeAttemptID.utf8)) } @Test func `websocket ping ignores duplicate success callbacks`() async throws { let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil]) try await WebSocketTaskBox(task: task).sendPing() } @Test func `websocket ping ignores duplicate callbacks after first error`() async throws { let firstError = URLError(.networkConnectionLost) let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil]) do { try await WebSocketTaskBox(task: task).sendPing() Issue.record("sendPing unexpectedly succeeded") } catch let error as URLError { #expect(error.code == firstError.code) } } @Test func `route invalidation follows the replaced channel owner`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let disconnects = DisconnectProbe() let invalidations = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { reason in await disconnects.record("first:\(reason)") }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await invalidations.record("first") }) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { reason in await disconnects.record("same:\(reason)") }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await invalidations.record("same") }) #expect(await (invalidations.values()).isEmpty) try await gateway.connect( url: #require(URL(string: "ws://second.example.invalid")), credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { reason in await disconnects.record("second:\(reason)") }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await invalidations.record("second") }) for _ in 0..<20 { await Task.yield() } let replacementDisconnects = await disconnects.values() #expect(replacementDisconnects.isEmpty) #expect(await invalidations.values() == ["same"]) await gateway.disconnect() for _ in 0..<20 { await Task.yield() } let finalDisconnects = await disconnects.values() #expect(finalDisconnects.isEmpty) #expect(await invalidations.values() == ["same", "second"]) } @Test func `connect joins the snapshot dispatched connected callback`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let connectedGate = AsyncGate() let lifecycle = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) let connect = Task { try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: { await lifecycle.record("connected-start") await connectedGate.wait() await lifecycle.record("connected-end") }, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) await lifecycle.record("connect-returned") } defer { connect.cancel() } try await waitUntil("connected callback suspended") { await connectedGate.hasStarted() } for _ in 0..<20 { await Task.yield() } #expect(await lifecycle.values() == ["connected-start"]) await connectedGate.release() try await connect.value #expect(await lifecycle.values() == [ "connected-start", "connected-end", "connect-returned", ]) await gateway.disconnect() } @Test func `concurrent replacements wait for route invalidation before installing a channel`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invalidationGate = AsyncGate() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await invalidationGate.wait() }) let supersededReplacement = Task { try await gateway.connect( url: #require(URL(string: "ws://second.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) } try await waitUntil("route invalidation started") { await invalidationGate.hasStarted() } let supersededAdmissionGeneration = await gateway._test_admissionGeneration() let finalReplacement = Task { try await gateway.connect( url: #require(URL(string: "ws://third.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) } try await waitUntil("final replacement revoked superseded admission") { await gateway._test_admissionGeneration() != supersededAdmissionGeneration } #expect(await gateway.currentRoute() == nil) #expect(session.snapshotMakeCount() == 1) await invalidationGate.release() do { try await supersededReplacement.value Issue.record("superseded replacement unexpectedly connected") } catch is CancellationError { // Expected: the final replacement advanced the generation while teardown waited. } try await finalReplacement.value #expect(await gateway.currentRoute() != nil) #expect(session.snapshotMakeCount() == 2) await gateway.disconnect() } @Test func `stale old invoke is rejected before onInvoke after route switch`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invocations = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let oldRoute = try #require(await gateway.currentRoute()) try await gateway.connect( url: #require(URL(string: "ws://replacement.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let response = await gateway.invokeIfCurrentRoute( BridgeInvokeRequest(id: "stale-computer", command: "computer.act", paramsJSON: "{}"), expectedRoute: oldRoute, onInvoke: { request in await invocations.record(request.id) return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: nil, error: nil) }) #expect(response.ok == false) #expect(response.error?.code == .unavailable) #expect(await invocations.values() == []) await gateway.disconnect() } @Test func `socket disconnect cancels a decoded invoke before delayed native admission`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invokeStarted = AsyncGate() let allowAdmission = AsyncGate() let invocations = DisconnectProbe() let disconnects = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { reason in await disconnects.record(reason) }, onInvoke: { request in await invokeStarted.wait() await allowAdmission.wait() guard !Task.isCancelled else { return BridgeInvokeResponse( id: request.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "UNAVAILABLE: canceled before native admission")) } await invocations.record(request.id) return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: nil, error: nil) }) let firstTask = try #require(session.latestTask()) try await waitUntil("receive loop armed before delayed invoke") { firstTask.hasPendingReceiveHandler() } firstTask.emitInvokeRequest(id: "stale-computer", command: "computer.act") try await waitUntil("delayed invoke started") { await invokeStarted.hasStarted() } await invokeStarted.release() try await waitUntil("receive loop rearmed before disconnect") { firstTask.hasPendingReceiveHandler() } firstTask.emitReceiveFailure() try await waitUntil("disconnect callback ran") { await !(disconnects.values()).isEmpty } await allowAdmission.release() try await waitUntil("replacement socket created") { session.snapshotMakeCount() >= 2 } #expect(await invocations.values() == []) #expect(firstTask.sentRequestCount(method: "node.invoke.result") == 0) await gateway.disconnect() } @Test func `route switch cancels an in flight push to talk start`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invokeStarted = AsyncGate() let cancellations = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["talk"], commands: ["talk.ptt.start"], permissions: [:], clientId: "openclaw-ios", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let route = try #require(await gateway.currentRoute()) let invoking = Task { await gateway.invokeIfCurrentRoute( BridgeInvokeRequest(id: "stale-ptt", command: "talk.ptt.start", paramsJSON: nil), expectedRoute: route, onInvoke: { request in await invokeStarted.wait() do { try await Task.sleep(nanoseconds: 60 * 1_000_000_000) } catch { await cancellations.record(request.id) } return BridgeInvokeResponse( id: request.id, ok: false, error: OpenClawNodeError(code: .unavailable, message: "UNAVAILABLE: route changed")) }) } try await waitUntil("push to talk invoke started") { await invokeStarted.hasStarted() } await invokeStarted.release() try await gateway.connect( url: #require(URL(string: "ws://replacement.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) #expect(await (invoking.value).ok == false) #expect(await cancellations.values() == ["stale-ptt"]) await gateway.disconnect() } @Test func `queued old socket invoke cannot adopt replacement admission after disconnect cleanup`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invocations = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { request in await invocations.record(request.id) return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: nil, error: nil) }) // Model an old push callback that was already queued on the session actor: // cleanup retires socket 1, then socket 2 becomes the active route before it runs. await gateway._test_handleChannelDisconnected("socket 1 lost", socketGeneration: 1) await gateway._test_handlePush( .event(EventFrame( type: "event", event: "tick", payload: nil, seq: nil, stateversion: nil)), socketGeneration: 2) await gateway._test_handlePush( nodeInvokePush(id: "queued-old-computer", command: "computer.act"), socketGeneration: 1) await gateway._test_handlePush( nodeInvokePush(id: "replacement-computer", command: "computer.act"), socketGeneration: 2) try await waitUntil("replacement invoke executed") { await invocations.values() == ["replacement-computer"] } #expect(await invocations.values() == ["replacement-computer"]) await gateway.disconnect() } @Test func `stale connect completion cannot notify after admission changes`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let lifecycle = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: { await lifecycle.record("connected") }, onDisconnected: { _ in await lifecycle.record("disconnected") }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) try await waitUntil("initial connected callback completed") { await lifecycle.values() == ["connected"] } let staleAdmissionGeneration = await gateway._test_admissionGeneration() await gateway._test_handleChannelDisconnected("socket lost", socketGeneration: 1) await gateway._test_notifyConnectedIfNeeded( admissionGeneration: staleAdmissionGeneration) try await waitUntil("disconnect callback completed") { await lifecycle.values().contains("disconnected") } #expect(await lifecycle.values() == ["connected", "disconnected"]) await gateway.disconnect() } @Test func `transport reconnect does not wait for blocked disconnect lifecycle`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invalidationGate = AsyncGate() let lifecycle = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: { await lifecycle.record("connected") }, onDisconnected: { _ in await lifecycle.record("disconnected") }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await invalidationGate.wait() }) let firstTask = try #require(session.latestTask()) try await waitUntil("receive loop armed before disconnect") { firstTask.hasPendingReceiveHandler() } firstTask.emitReceiveFailure() try await waitUntil("disconnect lifecycle blocked") { await invalidationGate.hasStarted() } try await waitUntil("replacement transport connected") { session.snapshotMakeCount() == 2 } #expect(await lifecycle.values() == ["connected"]) await invalidationGate.release() try await waitUntil("replacement lifecycle completed") { await lifecycle.values() == ["connected", "disconnected", "connected"] } await gateway.disconnect() } @Test func `replacement invoke fails promptly while disconnect lifecycle is blocked`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invalidationGate = AsyncGate() let invocations = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: ["system.which"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { request in await invocations.record(request.id) return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await invalidationGate.wait() }) let firstTask = try #require(session.latestTask()) try await waitUntil("receive loop armed before disconnect") { firstTask.hasPendingReceiveHandler() } firstTask.emitReceiveFailure() try await waitUntil("disconnect lifecycle blocked") { await invalidationGate.hasStarted() } try await waitUntil("replacement transport connected") { session.snapshotMakeCount() == 2 } let replacementTask = try #require(session.latestTask()) try await waitUntil("replacement socket receiving") { replacementTask.hasPendingReceiveHandler() } replacementTask.emitInvokeRequest(id: "during-lifecycle", command: "system.which") try await waitUntil("lifecycle unavailable result") { replacementTask.sentRequestCount(method: "node.invoke.result") == 1 } let result = try #require(replacementTask.sentRequests(method: "node.invoke.result").first) let params = try #require(result["params"] as? [String: Any]) let error = try #require(params["error"] as? [String: Any]) #expect(params["id"] as? String == "during-lifecycle") #expect(params["ok"] as? Bool == false) #expect(error["code"] as? String == OpenClawNodeErrorCode.unavailable.rawValue) #expect(error["message"] as? String == "UNAVAILABLE: node lifecycle transition in progress") #expect(await invocations.values() == []) await invalidationGate.release() await gateway.disconnect() } @Test func `disconnect cleanup finishes after an in flight connected callback`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let connectedGate = AsyncGate() let lifecycle = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) let connect = Task { try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: { await lifecycle.record("connected-start") await connectedGate.wait() await lifecycle.record("connected-end") }, onDisconnected: { _ in await lifecycle.record("disconnected") }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await lifecycle.record("invalidated") }) } try await waitUntil("connected callback suspended") { await connectedGate.hasStarted() } let firstTask = try #require(session.latestTask()) try await waitUntil("receive loop armed before disconnect") { firstTask.hasPendingReceiveHandler() } firstTask.emitReceiveFailure() try await waitUntil("route invalidated before connected callback returned") { await lifecycle.values().contains("invalidated") } let invalidatedLifecycle = await lifecycle.values() #expect(!invalidatedLifecycle.contains("disconnected")) await connectedGate.release() _ = try? await connect.value try await waitUntil("disconnect cleanup completed") { await lifecycle.values().contains("disconnected") } let completedLifecycle = await lifecycle.values() let orderedLifecycle = Array(completedLifecycle.prefix(4)) #expect(orderedLifecycle == [ "connected-start", "invalidated", "connected-end", "disconnected", ]) await gateway.disconnect() } @Test func `connected callback can disconnect its own route without deadlock`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let lifecycle = DisconnectProbe() let connectedExitGate = AsyncGate() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) let connect = Task { try? await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: { await lifecycle.record("connected-start") await gateway.disconnect() await lifecycle.record("disconnect-returned") await connectedExitGate.wait() await lifecycle.record("connected-end") }, onDisconnected: { _ in await lifecycle.record("disconnected") }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await lifecycle.record("invalidated") }) } defer { connect.cancel() } try await waitUntil("reentrant disconnect returned to connected callback") { await lifecycle.values().contains("disconnect-returned") } let replacement = Task { try await gateway.connect( url: #require(URL(string: "ws://second.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: {}) } defer { replacement.cancel() } for _ in 0..<20 { await Task.yield() } #expect(session.snapshotMakeCount() == 1) #expect(await gateway.currentRoute() == nil) await connectedExitGate.release() _ = await connect.value try await replacement.value #expect(await gateway.currentRoute() != nil) #expect(session.snapshotMakeCount() == 2) let values = await lifecycle.values() #expect(values.first == "connected-start") #expect(values.contains("invalidated")) #expect(values.contains("connected-end")) await gateway.disconnect() } @Test func `route invalidation callback can disconnect without awaiting its own teardown`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let lifecycle = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await lifecycle.record("invalidation-start") await gateway.disconnect() await lifecycle.record("invalidation-end") }) await gateway.disconnect() let values = await lifecycle.values() #expect(values.first == "invalidation-start") #expect(values.last == "invalidation-end") #expect(values.filter { $0 == "invalidation-start" }.count == values.filter { $0 == "invalidation-end" }.count) #expect(await gateway.currentRoute() == nil) } @Test func `socket loss releases connected callback pending request before disconnect cleanup`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let lifecycle = DisconnectProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) let connect = Task { try? await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: { do { _ = try await gateway.request( method: "node.test.pending", paramsJSON: nil, timeoutSeconds: 0) } catch { await lifecycle.record("request-failed") } }, onDisconnected: { _ in await lifecycle.record("disconnected") }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) } defer { connect.cancel() } try await waitUntil("connected callback issued pending request") { session.latestTask()?.sentRequestCount(method: "node.test.pending") == 1 } let firstTask = try #require(session.latestTask()) try await waitUntil("receive loop armed before pending request disconnect") { firstTask.hasPendingReceiveHandler() } firstTask.emitReceiveFailure() try await waitUntil("pending request and disconnect lifecycle both completed") { await lifecycle.values().contains("disconnected") } _ = await connect.value #expect(await lifecycle.values().prefix(2) == ["request-failed", "disconnected"]) await gateway.disconnect() } @Test func `detached channel cannot reconnect while route invalidation is suspended`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invalidationGate = AsyncGate() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: { await invalidationGate.wait() }) let firstTask = try #require(session.latestTask()) let replacement = Task { try await gateway.connect( url: #require(URL(string: "ws://second.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }, onRouteInvalidated: {}) } defer { replacement.cancel() } try await waitUntil("route invalidation suspended replacement") { await invalidationGate.hasStarted() } firstTask.emitReceiveFailure() try await Task.sleep(for: .milliseconds(650)) #expect(session.snapshotMakeCount() == 1) await invalidationGate.release() try await replacement.value #expect(session.snapshotMakeCount() == 2) await gateway.disconnect() } @Test func `upgrade request carries sanitized custom headers read per connect`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let secret = MutableHeaderValue(value: "first-secret") let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) let url = try #require(URL(string: "wss://gateway.example.invalid")) let provider: @Sendable () -> [String: String] = { [ "CF-Access-Client-Id": "client-id", "CF-Access-Client-Secret": secret.get(), "Host": "smuggled.example.invalid", ] } let connectOnce: () async throws -> Void = { try await gateway.connect( url: url, credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), extraHeadersProvider: provider, onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) } try await connectOnce() let request = try #require(session.latestRequest()) #expect(request.url == url) #expect(request.value(forHTTPHeaderField: "CF-Access-Client-Id") == "client-id") #expect(request.value(forHTTPHeaderField: "CF-Access-Client-Secret") == "first-secret") #expect(request.value(forHTTPHeaderField: "Host") == nil) // Header edits must ride the next upgrade without re-pairing or a new channel identity. secret.set("second-secret") await gateway.disconnect() try await connectOnce() let reconnectRequest = try #require(session.latestRequest()) #expect(reconnectRequest.value(forHTTPHeaderField: "CF-Access-Client-Secret") == "second-secret") await gateway.disconnect() } @Test func `cleartext upgrade never reads or attaches custom headers`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let secret = MutableHeaderValue(value: "must-not-be-read") let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://gateway.example.invalid")), credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), extraHeadersProvider: { [secret] in ["Authorization": secret.get()] }, onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let request = try #require(session.latestRequest()) #expect(secret.readCount() == 0) #expect(request.value(forHTTPHeaderField: "Authorization") == nil) await gateway.disconnect() } @Test func `server methods stay bound to the connected route`() async throws { let session = FakeGatewayWebSocketSession(helloMethods: [ "approval.get", "approval.resolve", "exec.approval.get", "exec.approval.resolve", ]) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "operator", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "operator", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://gateway.example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let route = try #require(await gateway.currentRoute()) #expect(await gateway.supportsServerMethod("approval.get", ifCurrentRoute: route) == true) #expect(await gateway.supportsServerMethod("missing", ifCurrentRoute: route) == false) await gateway.disconnect() #expect(await gateway.supportsServerMethod("approval.get", ifCurrentRoute: route) == nil) } @Test func `captured route bound operations never use a replacement channel`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let composedGatewayID = "gw-\u{00E9}" let decomposedGatewayID = "gw-e\u{0301}" let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false, deviceAuthGatewayID: composedGatewayID) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let firstRoute = try #require(await gateway.currentRoute(ifGatewayID: composedGatewayID)) #expect(composedGatewayID == decomposedGatewayID) #expect(await gateway.currentRoute(ifGatewayID: decomposedGatewayID) == nil) let capturedFirstRouteSender: @Sendable (String, String?) async -> Bool = { event, payloadJSON in await gateway.sendEvent( event: event, payloadJSON: payloadJSON, ifCurrentRoute: firstRoute) } var replacementOptions = options replacementOptions.deviceAuthGatewayID = decomposedGatewayID try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), credentials: .init(), connectOptions: replacementOptions, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) #expect(await gateway.currentRoute(ifGatewayID: composedGatewayID) == nil) #expect(await gateway.currentRoute(ifGatewayID: decomposedGatewayID) != nil) let sent = await capturedFirstRouteSender("push.apns.register", "{}") #expect(!sent) do { _ = try await gateway.request( method: "approval.get", paramsJSON: "{}", ifCurrentRoute: firstRoute) Issue.record("stale route request unexpectedly reached the replacement channel") } catch is CancellationError { // Expected: the route lease belongs to the first channel. } do { _ = try await gateway.request( method: "approval.get", paramsJSON: "{}", ifCurrentRoute: firstRoute, distinguishPreDispatchRouteChange: true) Issue.record("typed stale route request unexpectedly reached the replacement channel") } catch is GatewayNodeSessionRequestError { // Expected: callers can distinguish a request rejected before dispatch. } let replacementTask = try #require(session.latestTask()) #expect(replacementTask.sentRequestCount(method: "node.event") == 0) #expect(replacementTask.sentRequestCount(method: "approval.get") == 0) } @Test func `disconnect during channel shutdown prevents stale channel install`() async throws { let cancelGate = FirstCancelGate() let session = FakeGatewayWebSocketSession(cancelGate: cancelGate) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), credentials: .init(token: "first-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let replacement = Task { try await gateway.connect( url: #require(URL(string: "ws://stale.example.invalid")), credentials: .init(token: "stale-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) } let deadline = ContinuousClock().now.advanced(by: .seconds(2)) while !cancelGate.hasStarted(), ContinuousClock().now < deadline { await Task.yield() } #expect(cancelGate.hasStarted()) #expect(await gateway.currentRoute() == nil) let release = Task.detached { try? await Task.sleep(nanoseconds: 10_000_000) cancelGate.release() } await gateway.disconnect() await release.value do { try await replacement.value Issue.record("superseded replacement unexpectedly connected") } catch is CancellationError { // Expected: disconnect advanced the generation while old-channel shutdown was suspended. } #expect(session.snapshotMakeCount() == 1) } @Test func `invoke result is discarded after target switch`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let invokeStarted = AsyncStream.makeStream() let invokeRelease = AsyncStream.makeStream() var startedIterator = invokeStarted.stream.makeAsyncIterator() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: ["camera.snap"], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://first.example.invalid")), credentials: .init(token: "first-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { request in invokeStarted.continuation.yield() for await _ in invokeRelease.stream { return BridgeInvokeResponse( id: request.id, ok: true, payloadJSON: #"{"sensitive":"camera-result"}"#, error: nil) } return BridgeInvokeResponse(id: request.id, ok: false, payloadJSON: nil, error: nil) }) let firstTask = try #require(session.latestTask()) firstTask.emitInvokeRequest(id: "invoke-old", command: "camera.snap") _ = await startedIterator.next() try await gateway.connect( url: #require(URL(string: "ws://replacement.example.invalid")), credentials: .init(token: "replacement-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let replacementTask = try #require(session.latestTask()) invokeRelease.continuation.yield() invokeRelease.continuation.finish() for _ in 0..<100 { await Task.yield() } #expect(firstTask.sentRequestCount(method: "node.invoke.result") == 0) #expect(replacementTask.sentRequestCount(method: "node.invoke.result") == 0) await gateway.disconnect() } @Test func `node invoke requests keep receiving while system run is blocked`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let systemRunStarted = AsyncStream.makeStream() var startedIterator = systemRunStarted.stream.makeAsyncIterator() let systemRunRelease = AsyncStream.makeStream() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: ["system.run"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://example.invalid")), credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { request in if request.id == "system-run-blocked" { systemRunStarted.continuation.yield() for await _ in systemRunRelease.stream { return BridgeInvokeResponse( id: request.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "UNSUPPORTED: system.run unavailable")) } } return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: #"{"ok":true}"#) }) let task = try #require(session.latestTask()) task.emitInvokeRequest( id: "system-run-blocked", command: "system.run", paramsJSON: #"{"command":["/bin/echo","ok"]}"#) _ = await startedIterator.next() try await waitUntil("receive loop rearmed during system.run") { task.hasPendingReceiveHandler() } task.emitInvokeRequest(id: "camera-after-system-run", command: "camera.snap") try await waitUntil("second invoke result while system.run is blocked") { task.sentRequestCount(method: "node.invoke.result") == 1 } let earlyResults = task.sentRequests(method: "node.invoke.result") #expect(earlyResults.count == 1) let earlyParams = try #require(earlyResults.first?["params"] as? [String: Any]) #expect(earlyParams["id"] as? String == "camera-after-system-run") #expect(earlyParams["ok"] as? Bool == true) systemRunRelease.continuation.yield() systemRunRelease.continuation.finish() try await waitUntil("blocked system.run result") { task.sentRequestCount(method: "node.invoke.result") == 2 } let finalResults = task.sentRequests(method: "node.invoke.result") #expect(finalResults.count == 2) let blockedResult = try #require(finalResults.first { ($0["params"] as? [String: Any])?["id"] as? String == "system-run-blocked" }) let blockedParams = try #require(blockedResult["params"] as? [String: Any]) #expect(blockedParams["ok"] as? Bool == false) let error = try #require(blockedParams["error"] as? [String: Any]) #expect(error["code"] as? String == OpenClawNodeErrorCode.unavailable.rawValue) await gateway.disconnect() } @Test func `computer invoke receipts deduplicate in flight and after reconnect`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let probe = ComputerInvokeProbe() let options = GatewayConnectOptions( role: "node", scopes: [], caps: ["computer"], commands: ["computer.act"], permissions: [:], clientId: "openclaw-macos", clientMode: "node", clientDisplayName: "macOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://example.invalid")), token: nil, bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { request in await probe.execute(request) }) let firstTask = try #require(session.latestTask()) let idempotencyKey = "computer.act:v1:stable-call" let paramsJSON = #"{"action":"type","text":"hello","refWidth":1280}"# firstTask.emitInvokeRequest( id: "computer-first", command: "computer.act", paramsJSON: paramsJSON, idempotencyKey: idempotencyKey) try await waitUntil("first computer invoke started") { await probe.count() == 1 } try await waitUntil("receive loop rearmed during computer invoke") { firstTask.hasPendingReceiveHandler() } firstTask.emitInvokeRequest( id: "computer-in-flight-replay", command: "computer.act", paramsJSON: paramsJSON, idempotencyKey: idempotencyKey) for _ in 0..<20 { await Task.yield() } #expect(await probe.count() == 1) await probe.release() try await waitUntil("both in-flight computer receipts returned") { firstTask.sentRequestCount(method: "node.invoke.result") == 2 } try await waitUntil("receive loop rearmed before reconnect") { firstTask.hasPendingReceiveHandler() } firstTask.emitReceiveFailure() try await waitUntil("replacement socket created") { session.snapshotMakeCount() >= 2 } let replayTask = try #require(session.latestTask()) try await waitUntil("replacement socket receiving") { replayTask.hasPendingReceiveHandler() } replayTask.emitInvokeRequest( id: "computer-completed-replay", command: "computer.act", paramsJSON: paramsJSON, idempotencyKey: idempotencyKey) try await waitUntil("completed computer receipt returned after reconnect") { replayTask.sentRequestCount(method: "node.invoke.result") == 1 } #expect(await probe.count() == 1) try await waitUntil("replacement socket rearmed after replay") { replayTask.hasPendingReceiveHandler() } replayTask.emitInvokeRequest( id: "computer-key-mismatch", command: "computer.act", paramsJSON: #"{"action":"type","text":"different","refWidth":1280}"#, idempotencyKey: idempotencyKey) try await waitUntil("idempotency mismatch returned") { replayTask.sentRequestCount(method: "node.invoke.result") == 2 } let mismatch = try #require(replayTask.sentRequests(method: "node.invoke.result").last) let mismatchParams = try #require(mismatch["params"] as? [String: Any]) let mismatchError = try #require(mismatchParams["error"] as? [String: Any]) #expect(mismatchParams["ok"] as? Bool == false) #expect(mismatchError["code"] as? String == OpenClawNodeErrorCode.invalidRequest.rawValue) #expect(await probe.count() == 1) await gateway.disconnect() } @Test func `computer invoke receipts isolate canonically equivalent gateway owners`() async { let gateway = GatewayNodeSession() let probe = ComputerInvokeProbe() await probe.release() let paramsJSON = #"{"action":"type","text":"hello"}"# let idempotencyKey = "computer.act:v1:exact-owner" let composedScope = "gateway:gw-\u{00E9}" let decomposedScope = "gateway:gw-e\u{0301}" #expect(composedScope == decomposedScope) _ = await gateway.invokeComputerWithReceiptForTesting( requestId: "composed-owner", paramsJSON: paramsJSON, idempotencyKey: idempotencyKey, receiptScope: composedScope, onInvoke: { request in await probe.execute(request) }) _ = await gateway.invokeComputerWithReceiptForTesting( requestId: "decomposed-owner", paramsJSON: paramsJSON, idempotencyKey: idempotencyKey, receiptScope: decomposedScope, onInvoke: { request in await probe.execute(request) }) #expect(await probe.count() == 2) } @Test func `concurrent reconnect replays replace one stale receipt without duplicate input`() async throws { let gateway = GatewayNodeSession() let staleGate = AsyncGate() let freshProbe = ComputerInvokeProbe() let paramsJSON = #"{"action":"type","text":"hello"}"# let key = "computer.act:v1:stale-reconnect" let scope = "gateway:test" let stale = Task { await gateway.invokeComputerWithReceiptForTesting( requestId: "stale", paramsJSON: paramsJSON, idempotencyKey: key, receiptScope: scope, onInvoke: { request in await staleGate.wait() return BridgeInvokeResponse( id: request.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "UNAVAILABLE: node route changed before dispatch")) }) } try await waitUntil("stale receipt is in flight") { await staleGate.hasStarted() } let firstReplay = Task { await gateway.invokeComputerWithReceiptForTesting( requestId: "replay-1", paramsJSON: paramsJSON, idempotencyKey: key, receiptScope: scope, onInvoke: { request in await freshProbe.execute(request) }) } let secondReplay = Task { await gateway.invokeComputerWithReceiptForTesting( requestId: "replay-2", paramsJSON: paramsJSON, idempotencyKey: key, receiptScope: scope, onInvoke: { request in await freshProbe.execute(request) }) } try await waitUntil("both reconnect replays joined the stale receipt") { await gateway.computerReceiptJoinCountForTesting( idempotencyKey: key, receiptScope: scope) == 2 } await staleGate.release() try await waitUntil("fresh receipt executes once") { await freshProbe.count() == 1 } await freshProbe.release() #expect(await (stale.value).ok == false) #expect(await (firstReplay.value).ok) #expect(await (secondReplay.value).ok) #expect(await freshProbe.count() == 1) } @Test func `timed out computer receipt stays non evictable until operation settles`() async throws { let gateway = GatewayNodeSession() let blockedProbe = ComputerInvokeProbe() let replayProbe = ComputerInvokeProbe() await replayProbe.release() let scope = "gateway:timeout-capacity" let key = "computer.act:v1:unsettled" let paramsJSON = #"{"action":"type","text":"blocked"}"# let timedOut = await gateway.invokeComputerWithReceiptForTesting( requestId: "blocked-first", paramsJSON: paramsJSON, idempotencyKey: key, receiptScope: scope, timeoutMs: 1, onInvoke: { request in await blockedProbe.execute(request) }) #expect(!timedOut.ok) try await waitUntil("timed out operation remains active") { await blockedProbe.count() == 1 } // Fill past the bounded cache. Eviction may remove settled results, but // must preserve the timed-out receipt while its side effect is unresolved. for index in 0...256 { _ = await gateway.invokeComputerWithReceiptForTesting( requestId: "filler-\(index)", paramsJSON: #"{"action":"type","text":"filler"}"#, idempotencyKey: "computer.act:v1:filler-\(index)", receiptScope: scope, onInvoke: { request in BridgeInvokeResponse(id: request.id, ok: true) }) } let replay = await gateway.invokeComputerWithReceiptForTesting( requestId: "blocked-replay", paramsJSON: paramsJSON, idempotencyKey: key, receiptScope: scope, timeoutMs: 1, onInvoke: { request in await replayProbe.execute(request) }) #expect(!replay.ok) #expect(await replayProbe.count() == 0) #expect(await blockedProbe.count() == 1) await blockedProbe.release() } @Test(.stateDirectoryIsolated) func `scanned setup code prefers bootstrap auth over stored device token`() async throws { let identity = DeviceIdentityStore.loadOrCreate() _ = DeviceAuthStore.storeToken( deviceId: identity.deviceId, role: "operator", token: "stored-device-token") let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "operator", scopes: ["operator.read"], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "ui", clientDisplayName: "iOS Test", includeDeviceIdentity: true) try await gateway.connect( url: #require(URL(string: "ws://example.invalid")), credentials: .init(bootstrapToken: "fresh-bootstrap-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let auth = try #require(session.latestTask()?.latestConnectAuth()) #expect(auth["bootstrapToken"] as? String == "fresh-bootstrap-token") #expect(auth["token"] == nil) #expect(auth["deviceToken"] == nil) await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `credentialless setup handoff does not send a stored device token`() async throws { let identity = DeviceIdentityStore.loadOrCreate() _ = DeviceAuthStore.storeToken( deviceId: identity.deviceId, role: "node", token: "previous-gateway-device-token") let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: true, allowStoredDeviceAuth: false) try await gateway.connect( url: #require(URL(string: "ws://new-gateway.example.invalid")), credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let task = try #require(session.latestTask()) let auth = try #require(task.latestConnectAuth()) #expect(auth["token"] == nil) #expect(auth["bootstrapToken"] == nil) #expect(auth["deviceToken"] == nil) #expect(task.latestConnectDevice() != nil) #expect(await gateway.currentIssuedDeviceAuthRoles() == []) await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `stored device token cannot cross gateway owner`() async throws { let identity = DeviceIdentityStore.loadOrCreate() _ = DeviceAuthStore.storeToken( deviceId: identity.deviceId, role: "node", token: "gateway-a-device-token", gatewayID: "gateway-a") let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: true, allowStoredDeviceAuth: true, deviceAuthGatewayID: "gateway-b") try await gateway.connect( url: #require(URL(string: "ws://gateway-b.example.invalid")), credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let auth = try #require(session.latestTask()?.latestConnectAuth()) #expect(auth["token"] == nil) #expect(auth["deviceToken"] == nil) #expect(DeviceAuthStore.loadToken( deviceId: identity.deviceId, role: "node", gatewayID: "gateway-a")?.token == "gateway-a-device-token") await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `share extension identity profile uses separate node identity and token store`() async throws { let primaryIdentity = DeviceIdentityStore.loadOrCreate() _ = DeviceAuthStore.storeToken( deviceId: primaryIdentity.deviceId, role: "node", token: "primary-node-token") let session = FakeGatewayWebSocketSession(helloAuth: [ "deviceToken": "share-node-token", "role": "node", "scopes": [], ]) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios", clientMode: "node", clientDisplayName: "OpenClaw Share", deviceIdentityProfile: .shareExtension, includeDeviceIdentity: true) try await gateway.connect( url: #require(URL(string: "ws://example.invalid")), credentials: .init(password: "shared-password"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let shareDevice = try #require(session.latestTask()?.latestConnectDevice()) let shareDeviceId = try #require(shareDevice["id"] as? String) #expect(shareDeviceId != primaryIdentity.deviceId) #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")? .token == "primary-node-token") #expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil) #expect( DeviceAuthStore .loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token == "share-node-token") await gateway.disconnect() } @Test func `password takes precedence over bootstrap token`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "operator", scopes: ["operator.read"], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "ui", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "ws://example.invalid")), credentials: .init( bootstrapToken: "stale-bootstrap-token", password: "shared-password"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let auth = try #require(session.latestTask()?.latestConnectAuth()) #expect(auth["password"] as? String == "shared-password") #expect(auth["bootstrapToken"] == nil) #expect(auth["token"] == nil) await gateway.disconnect() } @Test func `connect failure preserves protocol mismatch details`() async throws { let session = FakeGatewayWebSocketSession(connectError: [ "code": "INVALID_REQUEST", "message": "protocol mismatch", "details": [ "code": "PROTOCOL_MISMATCH", "clientMinProtocol": 4, "clientMaxProtocol": 4, "expectedProtocol": 5, "minimumProbeProtocol": 4, ], ]) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "operator", scopes: ["operator.read"], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "ui", clientDisplayName: "iOS Test", includeDeviceIdentity: false) do { try await gateway.connect( url: #require(URL(string: "ws://example.invalid")), credentials: .init(token: "shared-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) Issue.record("connect unexpectedly succeeded") } catch let error as GatewayConnectAuthError { #expect(error.detail == .protocolMismatch) #expect(error.clientMinProtocol == 4) #expect(error.clientMaxProtocol == 4) #expect(error.expectedProtocol == 5) #expect(error.minimumProbeProtocol == 4) let problem = GatewayConnectionProblemMapper.map(error: error) #expect(problem?.kind == .protocolMismatch) #expect(problem?.owner == .iphone) #expect(problem? .message == "This app is older than the gateway. Update OpenClaw on this device, then retry.") #expect(problem?.pauseReconnect == true) #expect(problem?.retryable == false) } catch { Issue.record("unexpected error type: \(error)") } await gateway.disconnect() } @Test func `changed session box rebuilds existing gateway channel`() async throws { let firstSession = FakeGatewayWebSocketSession() let secondSession = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: false) try await gateway.connect( url: #require(URL(string: "wss://example.invalid")), credentials: .init(token: "shared-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: firstSession), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) try await gateway.connect( url: #require(URL(string: "wss://example.invalid")), credentials: .init(token: "shared-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: secondSession), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) #expect(firstSession.snapshotMakeCount() == 1) #expect(secondSession.snapshotMakeCount() == 1) await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `bootstrap hello stores additional device tokens`() async throws { let identity = DeviceIdentityStore.loadOrCreate() let session = FakeGatewayWebSocketSession(helloAuth: [ "deviceToken": "node-device-token", "role": "node", "scopes": [], "issuedAtMs": 1000, "deviceTokens": [ [ "deviceToken": "operator-device-token", "role": "operator", "scopes": [ "node.exec", "operator.admin", "operator.approvals", "operator.pairing", "operator.read", "operator.talk.secrets", "operator.write", ], "issuedAtMs": 1001, ], ], ]) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: true) try await gateway.connect( url: #require(URL(string: "wss://example.invalid")), credentials: .init(bootstrapToken: "fresh-bootstrap-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node")) let operatorEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator")) #expect(nodeEntry.token == "node-device-token") #expect(nodeEntry.scopes == []) #expect(operatorEntry.token == "operator-device-token") #expect(operatorEntry.scopes == [ "operator.approvals", "operator.read", "operator.talk.secrets", "operator.write", ]) #expect(await gateway.currentIssuedDeviceAuthRoles() == ["node", "operator"]) await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `failed device token write is not reported as an issued role`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false) try Data().write(to: blocker) // Repoint the pinned state dir at a plain file to force write failures; // the isolation trait restores the env var after the test. setenv("OPENCLAW_STATE_DIR", blocker.path, 1) defer { try? FileManager.default.removeItem(at: tempDir) } let session = FakeGatewayWebSocketSession(helloAuth: [ "deviceToken": "node-device-token", "role": "node", "scopes": [], ]) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: true) try await gateway.connect( url: #require(URL(string: "wss://example.invalid")), credentials: .init(bootstrapToken: "fresh-bootstrap-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) #expect(await gateway.currentIssuedDeviceAuthRoles().isEmpty) await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws { let identity = DeviceIdentityStore.loadOrCreate() let session = FakeGatewayWebSocketSession(helloAuth: [ "deviceToken": "server-node-token", "role": "node", "scopes": [], "deviceTokens": [ [ "deviceToken": "server-operator-token", "role": "operator", "scopes": ["operator.admin"], ], ], ]) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: true) try await gateway.connect( url: #require(URL(string: "wss://example.invalid")), credentials: .init(token: "shared-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node")) #expect(nodeEntry.token == "server-node-token") #expect(nodeEntry.scopes == []) #expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil) await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws { let identity = DeviceIdentityStore.loadOrCreate() let session = FakeGatewayWebSocketSession(helloAuth: [ "deviceToken": "untrusted-node-token", "role": "node", "scopes": [], "deviceTokens": [ [ "deviceToken": "untrusted-operator-token", "role": "operator", "scopes": [ "operator.approvals", "operator.read", ], ], ], ]) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: true) try await gateway.connect( url: #require(URL(string: "ws://example.invalid")), credentials: .init(bootstrapToken: "fresh-bootstrap-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) #expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node") == nil) #expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil) await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `private lan bootstrap persists handoff tokens for reconnect`() async throws { let identity = DeviceIdentityStore.loadOrCreate() let url = try #require(URL(string: "ws://192.168.50.164:18889")) let bootstrapSession = FakeGatewayWebSocketSession(helloAuth: [ "deviceToken": "lan-node-token", "role": "node", "scopes": [], "deviceTokens": [ [ "deviceToken": "lan-operator-token", "role": "operator", "scopes": [ "operator.approvals", "operator.read", ], ], ], ]) let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "node", scopes: [], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "node", clientDisplayName: "iOS Test", includeDeviceIdentity: true) try await gateway.connect( url: url, credentials: .init(bootstrapToken: "fresh-bootstrap-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: bootstrapSession), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) await gateway.disconnect() let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node")) let operatorEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator")) #expect(nodeEntry.token == "lan-node-token") #expect(nodeEntry.scopes == []) #expect(operatorEntry.token == "lan-operator-token") #expect(operatorEntry.scopes == [ "operator.approvals", "operator.read", ]) let reconnectSession = FakeGatewayWebSocketSession() try await gateway.connect( url: url, credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: reconnectSession), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let reconnectAuth = try #require(reconnectSession.latestTask()?.latestConnectAuth()) #expect(reconnectAuth["token"] as? String == "lan-node-token") #expect(reconnectAuth["bootstrapToken"] == nil) #expect(reconnectAuth["deviceToken"] == nil) await gateway.disconnect() } @Test(.stateDirectoryIsolated) func `token mismatch retries stored device token only for trusted loopback hosts`() async throws { let identity = DeviceIdentityStore.loadOrCreate() _ = DeviceAuthStore.storeToken( deviceId: identity.deviceId, role: "operator", token: "stored-device-token") let options = GatewayConnectOptions( role: "operator", scopes: ["operator.read"], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "ui", clientDisplayName: "iOS Test", includeDeviceIdentity: true) let connectError: [String: Any] = [ "code": GatewayConnectAuthDetailCode.authTokenMismatch.rawValue, "message": "token mismatch", "details": [ "canRetryWithDeviceToken": true, ], ] func retryAuth(for rawURL: String) async throws -> [String: Any] { let session = FakeGatewayWebSocketSession(connectError: connectError) let gateway = GatewayNodeSession() let url = try #require(URL(string: rawURL)) for _ in 0..<2 { do { try await gateway.connect( url: url, credentials: .init(token: "shared-gateway-token"), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) Issue.record("connect unexpectedly succeeded") } catch let error as GatewayConnectAuthError { #expect(error.detail == .authTokenMismatch) } } let retryAuth = try #require(session.latestTask()?.latestConnectAuth()) await gateway.disconnect() return retryAuth } for rawURL in [ "ws://127.attacker.example:18789", "ws://0.0.0.0:18789", "ws://[::]:18789", ] { let retryAuth = try await retryAuth(for: rawURL) #expect(retryAuth["token"] as? String == "shared-gateway-token") #expect(retryAuth["deviceToken"] == nil) } for rawURL in [ "ws://localhost:18789", "ws://127.0.0.2:18789", "ws://[::1]:18789", ] { let retryAuth = try await retryAuth(for: rawURL) #expect(retryAuth["token"] as? String == "shared-gateway-token") #expect(retryAuth["deviceToken"] as? String == "stored-device-token") } } @Test func `normalize canvas host url preserves explicit secure canvas port`() { 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 `normalize canvas host url backfills gateway host for loopback canvas`() { 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 `watch invoke payload decodes without bridge frame type`() throws { let data = Data( #"{"id":"invoke-1","nodeId":"watch-1","command":"device.info","paramsJSON":null,"timeoutMs":2000}"# .utf8) let request = try JSONDecoder().decode(NodeInvokeRequestEvent.self, from: data) #expect(request.id == "invoke-1") #expect(request.nodeid == "watch-1") #expect(request.command == "device.info") #expect(request.paramsjson == nil) #expect(request.timeoutms == 2000) } @Test func `invoke with timeout returns underlying response before timeout`() async { let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) let response = await GatewayNodeSession.invokeWithTimeout( request: request, timeoutMs: 50, onInvoke: { req in #expect(req.id == "1") return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil) }) #expect(response.ok == true) #expect(response.error == nil) #expect(response.payloadJSON == "{}") } @Test func `invoke with timeout returns timeout error`() async { let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil) let response = await GatewayNodeSession.invokeWithTimeout( request: request, timeoutMs: 10, onInvoke: { _ in try? await Task.sleep(nanoseconds: 200_000_000) // 200ms return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil) }) #expect(response.ok == false) #expect(response.error?.code == .unavailable) #expect(response.error?.message.contains("timed out") == true) } @Test func `invoke timeout cancels the in-flight operation`() async { let cancellation = InvokeCancellationFlag() let response = await GatewayNodeSession.invokeWithTimeout( request: BridgeInvokeRequest(id: "cancelled", command: "x", paramsJSON: nil), timeoutMs: 10, onInvoke: { request in await withTaskCancellationHandler { try? await Task.sleep(for: .seconds(1)) return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: nil, error: nil) } onCancel: { cancellation.markCancelled() } }) for _ in 0..<50 where !cancellation.isCancelled() { try? await Task.sleep(for: .milliseconds(1)) } #expect(response.ok == false) #expect(cancellation.isCancelled()) } @Test func `invoke with timeout zero disables timeout`() async { let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) let response = await GatewayNodeSession.invokeWithTimeout( request: request, timeoutMs: 0, onInvoke: { req in try? await Task.sleep(nanoseconds: 5_000_000) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) #expect(response.ok == true) #expect(response.error == nil) } @Test func `invoke timeout clamps hostile integer without trapping`() async { let response = await GatewayNodeSession.invokeWithTimeout( request: BridgeInvokeRequest(id: "large-timeout", command: "computer.act", paramsJSON: nil), timeoutMs: .max, onInvoke: { request in try? await Task.sleep(nanoseconds: 1_000_000) return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: nil, error: nil) }) #expect(response.ok == true) } @Test func `gateway request timeout zero disables the client deadline`() { #expect(GatewayChannelActor.resolveRequestTimeoutMs(0, defaultMs: 15000) == nil) #expect(GatewayChannelActor.resolveRequestTimeoutMs(nil, defaultMs: 15000) == 15000) #expect(GatewayChannelActor.resolveRequestTimeoutMs(30000, defaultMs: 15000) == 30000) } @Test func `server event subscription filters before buffering`() async { let gateway = GatewayNodeSession() let subscription = await gateway.makeServerEventSubscription( bufferingNewest: 1, matching: { $0.event == "target" }) defer { subscription.cancel() } let stream = subscription.events await gateway._test_broadcastServerEvent(EventFrame(type: "event", event: "noise")) await gateway._test_broadcastServerEvent(EventFrame(type: "event", event: "target")) await gateway._test_broadcastServerEvent(EventFrame(type: "event", event: "noise")) var iterator = stream.makeAsyncIterator() let event = await iterator.next() #expect(event?.event == "target") } @Test func `emits synthetic seq gap after reconnect snapshot`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let options = GatewayConnectOptions( role: "operator", scopes: ["operator.read"], caps: [], commands: [], permissions: [:], clientId: "openclaw-ios-test", clientMode: "ui", clientDisplayName: "iOS Test", includeDeviceIdentity: false) let stream = await gateway.subscribeServerEvents(bufferingNewest: 32) let probe = SeqGapProbe() let listenTask = Task { for await evt in stream { if evt.event == "seqGap" { await probe.mark() return } } } try await gateway.connect( url: #require(URL(string: "ws://example.invalid")), credentials: .init(), connectOptions: options, sessionBox: WebSocketSessionBox(session: session), onConnected: {}, onDisconnected: { _ in }, onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) }) let firstTask = try #require(session.latestTask()) firstTask.emitReceiveFailure() try await waitUntil("reconnect socket created") { session.snapshotMakeCount() >= 2 } try await waitUntil("synthetic seqGap broadcast") { await probe.value() } listenTask.cancel() await gateway.disconnect() } }