






















@@ -4,6 +4,7 @@ import OpenClawProtocol
44import Testing
55@testable import OpenClaw
667+@Suite(.serialized)
78struct GatewayChannelConnectTests {
89private final class ConnectParamsRecorder: @unchecked Sendable {
910private let lock = NSLock()
@@ -25,6 +26,23 @@ struct GatewayChannelConnectTests {
2526}
2627}
272829+private final class ScopeCapture: @unchecked Sendable {
30+private let lock = NSLock()
31+private var scopes: [String]?
32+33+func set(_ scopes: [String]?) {
34+self.lock.lock()
35+self.scopes = scopes
36+self.lock.unlock()
37+}
38+39+func snapshot() -> [String]? {
40+self.lock.lock()
41+defer { self.lock.unlock() }
42+return self.scopes
43+}
44+}
45+2846private final class TLSFailureSession: WebSocketSessioning, GatewayTLSFailureProviding, @unchecked Sendable {
2947private var failure: GatewayTLSValidationFailure?
3048@@ -92,6 +110,23 @@ struct GatewayChannelConnectTests {
92110})
93111}
94112113+private func withTemporaryStateDir<T>(_ operation: () async throws -> T) async throws -> T {
114+let tempDir = FileManager.default.temporaryDirectory
115+.appendingPathComponent(UUID().uuidString, isDirectory: true)
116+try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
117+let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
118+setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
119+defer {
120+if let previousStateDir {
121+setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
122+} else {
123+unsetenv("OPENCLAW_STATE_DIR")
124+}
125+try? FileManager.default.removeItem(at: tempDir)
126+}
127+return try await operation()
128+}
129+95130@Test func `concurrent connect is single flight on success`() async throws {
96131let session = self.makeSession(response: .helloOk(delayMs: 200))
97132let channel = try GatewayChannelActor(
@@ -152,6 +187,126 @@ struct GatewayChannelConnectTests {
152187 #expect(session.snapshotMakeCount() == 1)
153188}
154189190+ @Test func `default operator connect scopes preserve pairing and admin`() async throws {
191+try await self.withTemporaryStateDir {
192+let capture = ScopeCapture()
193+let session = GatewayTestWebSocketSession(
194+ taskFactory: {
195+GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
196+if sendIndex == 0 {
197+ capture.set(GatewayWebSocketTestSupport.connectScopes(from: message))
198+}
199+})
200+})
201+let channel = try GatewayChannelActor(
202+ url: #require(URL(string: "ws://example.invalid")),
203+ token: nil,
204+ session: WebSocketSessionBox(session: session))
205+206+try await channel.connect()
207+208+ #expect(capture.snapshot() == [
209+"operator.admin",
210+"operator.read",
211+"operator.write",
212+"operator.approvals",
213+"operator.pairing",
214+])
215+}
216+}
217+218+ @Test func `bootstrap token connect scopes are bootstrap-compatible`() async throws {
219+let capture = ScopeCapture()
220+let session = GatewayTestWebSocketSession(
221+ taskFactory: {
222+GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
223+if sendIndex == 0 {
224+ capture.set(GatewayWebSocketTestSupport.connectScopes(from: message))
225+}
226+})
227+})
228+let channel = try GatewayChannelActor(
229+ url: #require(URL(string: "ws://example.invalid")),
230+ token: nil,
231+ bootstrapToken: "setup-bootstrap-token",
232+ session: WebSocketSessionBox(session: session))
233+234+try await channel.connect()
235+236+ #expect(capture.snapshot() == [
237+"operator.approvals",
238+"operator.read",
239+"operator.write",
240+])
241+}
242+243+ @Test func `stored device token connect scopes reuse cached scopes`() async throws {
244+try await self.withTemporaryStateDir {
245+let identity = DeviceIdentityStore.loadOrCreate()
246+let storedEntry = DeviceAuthStore.storeToken(
247+ deviceId: identity.deviceId,
248+ role: "operator",
249+ token: "bootstrap-device-token",
250+ scopes: ["operator.read", "operator.write", "operator.approvals"])
251+let capture = ScopeCapture()
252+let session = GatewayTestWebSocketSession(
253+ taskFactory: {
254+GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
255+if sendIndex == 0 {
256+ capture.set(GatewayWebSocketTestSupport.connectScopes(from: message))
257+}
258+})
259+})
260+let channel = try GatewayChannelActor(
261+ url: #require(URL(string: "ws://example.invalid")),
262+ token: nil,
263+ session: WebSocketSessionBox(session: session))
264+265+try await channel.connect()
266+267+ #expect(capture.snapshot() == storedEntry.scopes)
268+}
269+}
270+271+ @Test func `explicit device token connect scopes preserve requested scopes`() async throws {
272+try await self.withTemporaryStateDir {
273+let identity = DeviceIdentityStore.loadOrCreate()
274+ _ = DeviceAuthStore.storeToken(
275+ deviceId: identity.deviceId,
276+ role: "operator",
277+ token: "bootstrap-device-token",
278+ scopes: ["operator.read", "operator.write", "operator.approvals"])
279+let requestedScopes = ["operator.admin", "operator.pairing"]
280+let capture = ScopeCapture()
281+let session = GatewayTestWebSocketSession(
282+ taskFactory: {
283+GatewayTestWebSocketTask(sendHook: { _, message, sendIndex in
284+if sendIndex == 0 {
285+ capture.set(GatewayWebSocketTestSupport.connectScopes(from: message))
286+}
287+})
288+})
289+let channel = try GatewayChannelActor(
290+ url: #require(URL(string: "ws://example.invalid")),
291+ token: nil,
292+ session: WebSocketSessionBox(session: session),
293+ connectOptions: GatewayConnectOptions(
294+ role: "operator",
295+ scopes: requestedScopes,
296+ scopesAreExplicit: true,
297+ caps: [],
298+ commands: [],
299+ permissions: [:],
300+ clientId: "openclaw-macos",
301+ clientMode: "ui",
302+ clientDisplayName: "OpenClaw macOS Debug CLI"))
303+304+try await channel.connect()
305+306+ #expect(capture.snapshot() == requestedScopes)
307+}
308+}
309+155310 @Test func `connect surfaces structured auth failure`() async throws {
156311let session = self.makeSession(response: .authFailed(
157312 delayMs: 0,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。