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 <steipete@gmail.com>
This commit is contained in:
ooiuuii
2026-07-03 05:13:19 +08:00
committed by GitHub
parent 968aa51b80
commit 155c2f4e7e
2 changed files with 96 additions and 4 deletions

View File

@@ -743,10 +743,11 @@ public actor GatewayChannelActor {
if scheme == "wss" {
return true
}
if let host = self.url.host, LoopbackHost.isLoopback(host) {
return true
}
return false
guard scheme == "ws", let host = self.url.host else { return false }
// Setup codes intentionally allow plaintext WebSocket bootstrap on local networks
// for QR pairing. Persist the resulting bounded device token so reconnects do not
// fall back to auth=none after the single-use bootstrap token is cleared.
return LoopbackHost.isLocalNetworkHost(host)
}
private func filteredBootstrapHandoffScopes(role: String, scopes: [String]) -> [String]? {

View File

@@ -760,6 +760,97 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func `private lan bootstrap persists handoff tokens for reconnect`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
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,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
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,
token: nil,
bootstrapToken: nil,
password: nil,
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
func `normalize canvas host url preserves explicit secure canvas port`() throws {
let normalized = try canonicalizeCanvasHostUrl(