Skip to content

Commit 155c2f4

Browse files
ooiuuiisteipete
andauthored
fix: keep iOS LAN QR pairing authenticated after bootstrap (#98066)
* Persist iOS LAN bootstrap handoff tokens * test: cover iOS LAN bootstrap reconnect auth * test(ios): consolidate LAN bootstrap reconnect proof --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 968aa51 commit 155c2f4

2 files changed

Lines changed: 96 additions & 4 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -743,10 +743,11 @@ public actor GatewayChannelActor {
743743
if scheme == "wss" {
744744
return true
745745
}
746-
if let host = self.url.host, LoopbackHost.isLoopback(host) {
747-
return true
748-
}
749-
return false
746+
guard scheme == "ws", let host = self.url.host else { return false }
747+
// Setup codes intentionally allow plaintext WebSocket bootstrap on local networks
748+
// for QR pairing. Persist the resulting bounded device token so reconnects do not
749+
// fall back to auth=none after the single-use bootstrap token is cleared.
750+
return LoopbackHost.isLocalNetworkHost(host)
750751
}
751752

752753
private func filteredBootstrapHandoffScopes(role: String, scopes: [String]) -> [String]? {

apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,97 @@ struct GatewayNodeSessionTests {
760760
await gateway.disconnect()
761761
}
762762

763+
@Test
764+
func `private lan bootstrap persists handoff tokens for reconnect`() async throws {
765+
let tempDir = FileManager.default.temporaryDirectory
766+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
767+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
768+
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
769+
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
770+
defer {
771+
if let previousStateDir {
772+
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
773+
} else {
774+
unsetenv("OPENCLAW_STATE_DIR")
775+
}
776+
try? FileManager.default.removeItem(at: tempDir)
777+
}
778+
779+
let identity = DeviceIdentityStore.loadOrCreate()
780+
let url = try #require(URL(string: "ws://192.168.50.164:18889"))
781+
let bootstrapSession = FakeGatewayWebSocketSession(helloAuth: [
782+
"deviceToken": "lan-node-token",
783+
"role": "node",
784+
"scopes": [],
785+
"deviceTokens": [
786+
[
787+
"deviceToken": "lan-operator-token",
788+
"role": "operator",
789+
"scopes": [
790+
"operator.approvals",
791+
"operator.read",
792+
],
793+
],
794+
],
795+
])
796+
let gateway = GatewayNodeSession()
797+
let options = GatewayConnectOptions(
798+
role: "node",
799+
scopes: [],
800+
caps: [],
801+
commands: [],
802+
permissions: [:],
803+
clientId: "openclaw-ios-test",
804+
clientMode: "node",
805+
clientDisplayName: "iOS Test",
806+
includeDeviceIdentity: true)
807+
808+
try await gateway.connect(
809+
url: url,
810+
token: nil,
811+
bootstrapToken: "fresh-bootstrap-token",
812+
password: nil,
813+
connectOptions: options,
814+
sessionBox: WebSocketSessionBox(session: bootstrapSession),
815+
onConnected: {},
816+
onDisconnected: { _ in },
817+
onInvoke: { req in
818+
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
819+
})
820+
await gateway.disconnect()
821+
822+
let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node"))
823+
let operatorEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator"))
824+
#expect(nodeEntry.token == "lan-node-token")
825+
#expect(nodeEntry.scopes == [])
826+
#expect(operatorEntry.token == "lan-operator-token")
827+
#expect(operatorEntry.scopes == [
828+
"operator.approvals",
829+
"operator.read",
830+
])
831+
832+
let reconnectSession = FakeGatewayWebSocketSession()
833+
try await gateway.connect(
834+
url: url,
835+
token: nil,
836+
bootstrapToken: nil,
837+
password: nil,
838+
connectOptions: options,
839+
sessionBox: WebSocketSessionBox(session: reconnectSession),
840+
onConnected: {},
841+
onDisconnected: { _ in },
842+
onInvoke: { req in
843+
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
844+
})
845+
846+
let reconnectAuth = try #require(reconnectSession.latestTask()?.latestConnectAuth())
847+
#expect(reconnectAuth["token"] as? String == "lan-node-token")
848+
#expect(reconnectAuth["bootstrapToken"] == nil)
849+
#expect(reconnectAuth["deviceToken"] == nil)
850+
851+
await gateway.disconnect()
852+
}
853+
763854
@Test
764855
func `normalize canvas host url preserves explicit secure canvas port`() throws {
765856
let normalized = try canonicalizeCanvasHostUrl(

0 commit comments

Comments
 (0)