




























@@ -0,0 +1,159 @@
1+import Foundation
2+import OpenClawKit
3+import Testing
4+5+private extension NSLock {
6+func withDeviceRetryLock<T>(_ body: () -> T) -> T {
7+self.lock()
8+defer { self.unlock() }
9+return body()
10+}
11+}
12+13+private final class ConnectAuthRecorder: @unchecked Sendable {
14+private let lock = NSLock()
15+private var auths: [[String: Any]] = []
16+17+func append(from message: URLSessionWebSocketTask.Message) {
18+guard let auth = Self.connectAuth(from: message) else { return }
19+self.lock.withDeviceRetryLock {
20+self.auths.append(auth)
21+}
22+}
23+24+func auth(at index: Int) -> [String: Any]? {
25+self.lock.withDeviceRetryLock {
26+guard self.auths.indices.contains(index) else { return nil }
27+return self.auths[index]
28+}
29+}
30+31+private static func connectAuth(from message: URLSessionWebSocketTask.Message) -> [String: Any]? {
32+let data: Data? = switch message {
33+case let .data(raw):
34+ raw
35+case let .string(text):
36+Data(text.utf8)
37+@unknown default:
38+nil
39+}
40+guard let data,
41+let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
42+json["type"] as? String == "req",
43+json["method"] as? String == "connect",
44+let params = json["params"] as? [String: Any],
45+let auth = params["auth"] as? [String: Any]
46+else {
47+return nil
48+}
49+return auth
50+}
51+}
52+53+private final class TrustedDeviceRetryGatewaySession: WebSocketSessioning, GatewayDeviceTokenRetryTrustProviding, @unchecked Sendable {
54+let allowsDeviceTokenRetryAuth: Bool
55+56+private let lock = NSLock()
57+private let recorder: ConnectAuthRecorder
58+private var makeCount = 0
59+60+init(recorder: ConnectAuthRecorder, allowsDeviceTokenRetryAuth: Bool) {
61+self.recorder = recorder
62+self.allowsDeviceTokenRetryAuth = allowsDeviceTokenRetryAuth
63+}
64+65+func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
66+ _ = url
67+let attemptIndex = self.lock.withDeviceRetryLock { () -> Int in
68+let current = self.makeCount
69+self.makeCount += 1
70+return current
71+}
72+let recorder = self.recorder
73+let task = GatewayTestWebSocketTask(
74+ sendHook: { _, message, sendIndex in
75+if sendIndex == 0 {
76+ recorder.append(from: message)
77+}
78+},
79+ receiveHook: { task, receiveIndex in
80+if receiveIndex == 0 {
81+return .data(GatewayWebSocketTestSupport.connectChallengeData())
82+}
83+let id = task.snapshotConnectRequestID() ?? "connect"
84+if attemptIndex == 0 {
85+return .data(GatewayWebSocketTestSupport.connectAuthFailureData(
86+ id: id,
87+ detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
88+ canRetryWithDeviceToken: true,
89+ recommendedNextStep: GatewayConnectRecoveryNextStep.retryWithDeviceToken.rawValue))
90+}
91+return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
92+})
93+return WebSocketTaskBox(task: task)
94+}
95+}
96+97+@Suite(.serialized)
98+struct GatewayChannelDeviceTokenRetryTests {
99+@Test func `remote pinned TLS retries stale shared token with stored device token`() async throws {
100+let tempDir = FileManager.default.temporaryDirectory
101+.appendingPathComponent(UUID().uuidString, isDirectory: true)
102+try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
103+let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
104+setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
105+defer {
106+if let previousStateDir {
107+setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
108+} else {
109+unsetenv("OPENCLAW_STATE_DIR")
110+}
111+try? FileManager.default.removeItem(at: tempDir)
112+}
113+114+let identity = DeviceIdentityStore.loadOrCreate()
115+ _ = DeviceAuthStore.storeToken(
116+ deviceId: identity.deviceId,
117+ role: "operator",
118+ token: "stored-device-token")
119+120+let recorder = ConnectAuthRecorder()
121+let session = TrustedDeviceRetryGatewaySession(
122+ recorder: recorder,
123+ allowsDeviceTokenRetryAuth: true)
124+let options = GatewayConnectOptions(
125+ role: "operator",
126+ scopes: ["operator.read"],
127+ caps: [],
128+ commands: [],
129+ permissions: [:],
130+ clientId: "openclaw-ios-test",
131+ clientMode: "ui",
132+ clientDisplayName: "iOS Test",
133+ includeDeviceIdentity: true)
134+let channel = try GatewayChannelActor(
135+ url: #require(URL(string: "wss://gateway.example.com")),
136+ token: "stale-shared-token",
137+ session: WebSocketSessionBox(session: session),
138+ connectOptions: options)
139+140+do {
141+try await channel.connect()
142+Issue.record("expected stale shared-token connect to fail before device-token retry")
143+} catch let error as GatewayConnectAuthError {
144+ #expect(error.detail == .authTokenMismatch)
145+}
146+147+try await channel.connect()
148+149+let firstAuth = try #require(recorder.auth(at: 0))
150+ #expect(firstAuth["token"] as? String == "stale-shared-token")
151+ #expect(firstAuth["deviceToken"] == nil)
152+153+let retryAuth = try #require(recorder.auth(at: 1))
154+ #expect(retryAuth["token"] as? String == "stale-shared-token")
155+ #expect(retryAuth["deviceToken"] as? String == "stored-device-token")
156+157+await channel.shutdown()
158+}
159+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。