From 981003591c99e2eb1573754672f5dfc52ad068c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Jun 2026 13:25:56 +0100 Subject: [PATCH] fix(channels): stop duplicating inbound previews in system events --- .../ShareExtension/ShareViewController.swift | 3 +- .../Gateway/GatewayConnectConfig.swift | 1 + .../Onboarding/GatewayOnboardingReset.swift | 1 + .../Sources/OpenClawKit/DeviceAuthStore.swift | 43 +++-- .../Sources/OpenClawKit/DeviceIdentity.swift | 84 +++++++-- .../Sources/OpenClawKit/GatewayChannel.swift | 45 +++-- .../OpenClawKit/GatewayNodeSession.swift | 2 + .../DeviceIdentityStoreTests.swift | 115 ++++++++++-- .../GatewayNodeSessionTests.swift | 169 +++++++++++++----- extensions/github-copilot/embeddings.test.ts | 80 +++++++++ extensions/github-copilot/embeddings.ts | 12 +- extensions/google-meet/src/calendar.ts | 2 - extensions/google-meet/src/drive.ts | 2 - .../google-meet/src/google-api-errors.test.ts | 47 +++++ .../google-meet/src/google-api-errors.ts | 13 +- extensions/google-meet/src/meet.ts | 8 - extensions/google-meet/src/oauth.ts | 3 +- .../tools/gateway-tool-guard-coverage.test.ts | 32 +--- src/agents/tools/gateway-tool.ts | 3 - 19 files changed, 523 insertions(+), 142 deletions(-) create mode 100644 extensions/google-meet/src/google-api-errors.test.ts diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift index b7d2bedf5b27..1d8e44394a45 100644 --- a/apps/ios/ShareExtension/ShareViewController.swift +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -184,7 +184,8 @@ final class ShareViewController: UIViewController { clientId: clientId, clientMode: "node", clientDisplayName: "OpenClaw Share", - includeDeviceIdentity: false) + deviceIdentityProfile: .shareExtension, + includeDeviceIdentity: true) } do { diff --git a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift index da7265b915d4..bb1c6e672e7a 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift @@ -62,6 +62,7 @@ struct GatewayConnectConfig { lhs.clientId == rhs.clientId && lhs.clientMode == rhs.clientMode && lhs.clientDisplayName == rhs.clientDisplayName && + lhs.deviceIdentityProfile == rhs.deviceIdentityProfile && lhs.includeDeviceIdentity == rhs.includeDeviceIdentity && lhsScopes == rhsScopes && lhsCaps == rhsCaps && diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift index e82880de0022..e9c66c372d9a 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift @@ -18,6 +18,7 @@ enum GatewayOnboardingReset { let deviceId = DeviceIdentityStore.loadOrCreate().deviceId DeviceAuthStore.clearToken(deviceId: deviceId, role: "node") DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator") + DeviceAuthStore.clearAll(profile: .shareExtension) GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults) GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift index 5ba934490af6..2b13e3e96834 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift @@ -21,10 +21,12 @@ private struct DeviceAuthStoreFile: Codable { } public enum DeviceAuthStore { - private static let fileName = "device-auth.json" - - public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? { - guard let store = readStore(), store.deviceId == deviceId else { return nil } + public static func loadToken( + deviceId: String, + role: String, + profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry? + { + guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil } let role = self.normalizeRole(role) return store.tokens[role] } @@ -33,10 +35,11 @@ public enum DeviceAuthStore { deviceId: String, role: String, token: String, - scopes: [String] = []) -> DeviceAuthEntry + scopes: [String] = [], + profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry { let normalizedRole = self.normalizeRole(role) - var next = self.readStore() + var next = self.readStore(profile: profile) if next?.deviceId != deviceId { next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) } @@ -50,17 +53,25 @@ public enum DeviceAuthStore { } next?.tokens[normalizedRole] = entry if let store = next { - self.writeStore(store) + self.writeStore(store, profile: profile) } return entry } - public static func clearToken(deviceId: String, role: String) { - guard var store = readStore(), store.deviceId == deviceId else { return } + public static func clearToken( + deviceId: String, + role: String, + profile: GatewayDeviceIdentityProfile = .primary) + { + guard var store = readStore(profile: profile), store.deviceId == deviceId else { return } let normalizedRole = self.normalizeRole(role) guard store.tokens[normalizedRole] != nil else { return } store.tokens.removeValue(forKey: normalizedRole) - self.writeStore(store) + self.writeStore(store, profile: profile) + } + + public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) { + try? FileManager.default.removeItem(at: self.fileURL(profile: profile)) } private static func normalizeRole(_ role: String) -> String { @@ -74,14 +85,14 @@ public enum DeviceAuthStore { return Array(Set(trimmed)).sorted() } - private static func fileURL() -> URL { + private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL { DeviceIdentityPaths.stateDirURL() .appendingPathComponent("identity", isDirectory: true) - .appendingPathComponent(self.fileName, isDirectory: false) + .appendingPathComponent(profile.authFileName, isDirectory: false) } - private static func readStore() -> DeviceAuthStoreFile? { - let url = self.fileURL() + private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? { + let url = self.fileURL(profile: profile) guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else { return nil @@ -90,8 +101,8 @@ public enum DeviceAuthStore { return decoded } - private static func writeStore(_ store: DeviceAuthStoreFile) { - let url = self.fileURL() + private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) { + let url = self.fileURL(profile: profile) do { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift index 539d8c39fed7..950c31c99516 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift @@ -1,6 +1,29 @@ import CryptoKit import Foundation +public enum GatewayDeviceIdentityProfile: String, Sendable { + case primary + case shareExtension + + var identityFileName: String { + switch self { + case .primary: + "device.json" + case .shareExtension: + "share-device.json" + } + } + + var authFileName: String { + switch self { + case .primary: + "device-auth.json" + case .shareExtension: + "share-device-auth.json" + } + } +} + public struct DeviceIdentity: Codable, Sendable { public var deviceId: String public var publicKey: String @@ -19,6 +42,32 @@ enum DeviceIdentityPaths { private static let stateDirEnv = ["OPENCLAW_STATE_DIR"] static func stateDirURL() -> URL { + self.stateDirURL( + overrideURL: self.stateDirOverrideURL(), + legacyStateDirURL: self.legacyStateDirURL(), + appGroupStateDirURL: self.appGroupStateDirURL(), + temporaryDirectory: FileManager.default.temporaryDirectory) + } + + static func stateDirURL( + overrideURL: URL?, + legacyStateDirURL: URL?, + appGroupStateDirURL: URL?, + temporaryDirectory: URL) -> URL + { + if let overrideURL { + return overrideURL + } + if let appGroupStateDirURL { + return appGroupStateDirURL + } + if let legacyStateDirURL { + return legacyStateDirURL + } + return temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true) + } + + private static func stateDirOverrideURL() -> URL? { for key in self.stateDirEnv { if let raw = getenv(key) { let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) @@ -27,34 +76,49 @@ enum DeviceIdentityPaths { } } } + return nil + } + private static func legacyStateDirURL() -> URL? { if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { return appSupport.appendingPathComponent("OpenClaw", isDirectory: true) } + return nil + } - return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true) + private static func appGroupStateDirURL() -> URL? { + guard + let containerURL = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier) + else { + return nil + } + return containerURL.appendingPathComponent("OpenClaw", isDirectory: true) } } public enum DeviceIdentityStore { - private static let fileName = "device.json" private static let ed25519SPKIPrefix = Data([ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, + 0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x70, 0x03, 0x21, 0x00, ]) private static let ed25519PKCS8PrivatePrefix = Data([ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, - 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, + 0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, + 0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ]) public static func loadOrCreate() -> DeviceIdentity { - self.loadOrCreate(fileURL: self.fileURL()) + self.loadOrCreate(profile: .primary) + } + + public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity { + self.loadOrCreate(fileURL: self.fileURL(profile: profile)) } static func loadOrCreate(fileURL url: URL) -> DeviceIdentity { if let data = try? Data(contentsOf: url) { switch self.decodeStoredIdentity(data) { - case .identity(let decoded): + case let .identity(decoded): return decoded case .recognizedInvalid: return self.generate() @@ -143,7 +207,7 @@ public enum DeviceIdentityStore { let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil } - guard publicKeyData.count == 32 && privateKeyData.count == 32, + guard publicKeyData.count == 32, privateKeyData.count == 32, self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData) else { return nil } return DeviceIdentity( @@ -211,11 +275,11 @@ public enum DeviceIdentityStore { } } - private static func fileURL() -> URL { + private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL { let base = DeviceIdentityPaths.stateDirURL() return base .appendingPathComponent("identity", isDirectory: true) - .appendingPathComponent(self.fileName, isDirectory: false) + .appendingPathComponent(profile.identityFileName, isDirectory: false) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index c34c6bf77912..9ab5c8ccb1fb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -107,6 +107,7 @@ public struct GatewayConnectOptions: Sendable { public var clientId: String public var clientMode: String public var clientDisplayName: String? + public var deviceIdentityProfile: GatewayDeviceIdentityProfile /// When false, the connection omits the signed device identity payload and cannot use /// device-scoped auth (role/scope upgrades will require pairing). Keep this true for /// role/scoped sessions such as operator UI clients. @@ -122,6 +123,7 @@ public struct GatewayConnectOptions: Sendable { clientId: String, clientMode: String, clientDisplayName: String?, + deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary, includeDeviceIdentity: Bool = true) { self.role = role @@ -133,6 +135,7 @@ public struct GatewayConnectOptions: Sendable { self.clientId = clientId self.clientMode = clientMode self.clientDisplayName = clientDisplayName + self.deviceIdentityProfile = deviceIdentityProfile self.includeDeviceIdentity = includeDeviceIdentity } } @@ -436,13 +439,15 @@ public actor GatewayChannelActor { let clientId = options.clientId let clientMode = options.clientMode let role = options.role + let deviceIdentityProfile = options.deviceIdentityProfile let requestedScopes = options.scopes let scopesAreExplicit = options.scopesAreExplicit let includeDeviceIdentity = options.includeDeviceIdentity - let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil + let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil let selectedAuth = self.selectConnectAuth( role: role, includeDeviceIdentity: includeDeviceIdentity, + deviceIdentityProfile: deviceIdentityProfile, deviceId: identity?.deviceId, requestedScopes: requestedScopes) let scopes = self.resolveConnectScopes( @@ -532,7 +537,11 @@ public actor GatewayChannelActor { try await self.task?.send(.data(data)) do { let response = try await self.waitForConnectResponse(reqId: reqId) - try await self.handleConnectResponse(response, identity: identity, role: role) + try await self.handleConnectResponse( + response, + identity: identity, + role: role, + deviceIdentityProfile: deviceIdentityProfile) self.pendingDeviceTokenRetry = false self.deviceTokenRetryBudgetUsed = false } catch { @@ -550,7 +559,10 @@ public actor GatewayChannelActor { self.shouldClearStoredDeviceTokenAfterRetry(error) { // Retry failed with an explicit device-token mismatch; clear stale local token. - DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role) + DeviceAuthStore.clearToken( + deviceId: identity.deviceId, + role: role, + profile: deviceIdentityProfile) } throw error } @@ -559,6 +571,7 @@ public actor GatewayChannelActor { private func selectConnectAuth( role: String, includeDeviceIdentity: Bool, + deviceIdentityProfile: GatewayDeviceIdentityProfile, deviceId: String?, requestedScopes: [String]) -> SelectedConnectAuth { @@ -568,7 +581,7 @@ public actor GatewayChannelActor { let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let storedEntry = (includeDeviceIdentity && deviceId != nil) - ? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role) + ? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile) : nil let storedToken = storedEntry?.token let storedScopes = storedEntry?.scopes ?? [] @@ -756,7 +769,8 @@ public actor GatewayChannelActor { deviceId: String, role: String, token: String, - scopes: [String]) + scopes: [String], + deviceIdentityProfile: GatewayDeviceIdentityProfile) { guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else { return @@ -765,7 +779,8 @@ public actor GatewayChannelActor { deviceId: deviceId, role: role, token: token, - scopes: filteredScopes) + scopes: filteredScopes, + profile: deviceIdentityProfile) } private func persistIssuedDeviceToken( @@ -773,7 +788,8 @@ public actor GatewayChannelActor { deviceId: String, role: String, token: String, - scopes: [String]) + scopes: [String], + deviceIdentityProfile: GatewayDeviceIdentityProfile) { if authSource == .bootstrapToken { guard self.shouldPersistBootstrapHandoffTokens() else { @@ -783,20 +799,23 @@ public actor GatewayChannelActor { deviceId: deviceId, role: role, token: token, - scopes: scopes) + scopes: scopes, + deviceIdentityProfile: deviceIdentityProfile) return } _ = DeviceAuthStore.storeToken( deviceId: deviceId, role: role, token: token, - scopes: scopes) + scopes: scopes, + profile: deviceIdentityProfile) } private func handleConnectResponse( _ res: ResponseFrame, identity: DeviceIdentity?, - role: String) async throws + role: String, + deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws { if res.ok == false { let error = res.error @@ -855,7 +874,8 @@ public actor GatewayChannelActor { deviceId: identity.deviceId, role: authRole, token: deviceToken, - scopes: scopes) + scopes: scopes, + deviceIdentityProfile: deviceIdentityProfile) } if self.shouldPersistBootstrapHandoffTokens(), let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable] @@ -873,7 +893,8 @@ public actor GatewayChannelActor { deviceId: identity.deviceId, role: authRole, token: deviceToken, - scopes: scopes) + scopes: scopes, + deviceIdentityProfile: deviceIdentityProfile) } } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 4cf1846aed55..b71c06f534aa 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -162,6 +162,7 @@ public actor GatewayNodeSession { let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines) let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines) let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let deviceIdentityProfile = options.deviceIdentityProfile.rawValue let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0" let permissions = options.permissions .map { key, value in @@ -179,6 +180,7 @@ public actor GatewayNodeSession { clientId, clientMode, clientDisplayName, + deviceIdentityProfile, includeDeviceIdentity, permissions, ].joined(separator: "|") diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift index 2e6b178b4849..da6cd7d031de 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -5,8 +5,99 @@ import Testing @Suite(.serialized) struct DeviceIdentityStoreTests { - @Test("loads TypeScript PEM identity schema without rewriting or regenerating") - func loadsTypeScriptPEMIdentitySchema() throws { + @Test + func `state directory override wins over shared app group storage`() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let overrideURL = tempDir.appendingPathComponent("override", isDirectory: true) + let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true) + let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true) + + let selected = DeviceIdentityPaths.stateDirURL( + overrideURL: overrideURL, + legacyStateDirURL: legacyURL, + appGroupStateDirURL: sharedURL, + temporaryDirectory: tempDir) + + #expect(selected == overrideURL) + #expect(!FileManager.default.fileExists(atPath: sharedURL.path)) + } + + @Test + func `shared app group storage wins over legacy app support storage`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true) + let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true) + let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true) + let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false) + let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true) + let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false) + try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true) + try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8) + + let selected = DeviceIdentityPaths.stateDirURL( + overrideURL: nil, + legacyStateDirURL: legacyURL, + appGroupStateDirURL: sharedURL, + temporaryDirectory: tempDir) + + #expect(selected == sharedURL) + #expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path)) + } + + @Test + func `share extension profile uses separate identity and auth files`() 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 primaryIdentity = DeviceIdentityStore.loadOrCreate() + let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension) + _ = DeviceAuthStore.storeToken( + deviceId: primaryIdentity.deviceId, + role: "node", + token: "primary-token") + _ = DeviceAuthStore.storeToken( + deviceId: shareIdentity.deviceId, + role: "node", + token: "share-token", + profile: .shareExtension) + + let identityDir = tempDir.appendingPathComponent("identity", isDirectory: true) + #expect(primaryIdentity.deviceId != shareIdentity.deviceId) + #expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device.json").path)) + #expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("share-device.json").path)) + #expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path)) + #expect(FileManager.default + .fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path)) + #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token") + #expect( + DeviceAuthStore + .loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension)?.token == + "share-token") + + DeviceAuthStore.clearAll(profile: .shareExtension) + + #expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token") + #expect(DeviceAuthStore + .loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension) == nil) + } + + @Test + func `loads TypeScript PEM identity schema without rewriting or regenerating`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) let identityURL = tempDir @@ -40,8 +131,8 @@ struct DeviceIdentityStoreTests { #expect(try String(contentsOf: identityURL, encoding: .utf8) == before) } - @Test("does not overwrite a recognized invalid TypeScript identity schema") - func preservesInvalidTypeScriptPEMIdentitySchema() throws { + @Test + func `does not overwrite a recognized invalid TypeScript identity schema`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) let identityURL = tempDir @@ -52,14 +143,14 @@ struct DeviceIdentityStoreTests { at: identityURL.deletingLastPathComponent(), withIntermediateDirectories: true) let stored = """ - { - "version": 1, - "deviceId": "stale-device-id", - "publicKeyPem": "not-a-valid-public-key", - "privateKeyPem": "not-a-valid-private-key", - "createdAtMs": 1700000000000 - } - """ + { + "version": 1, + "deviceId": "stale-device-id", + "publicKeyPem": "not-a-valid-public-key", + "privateKeyPem": "not-a-valid-private-key", + "createdAtMs": 1700000000000 + } + """ try stored.write(to: identityURL, atomically: true, encoding: .utf8) let before = try String(contentsOf: identityURL, encoding: .utf8) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index c8fef5aeb8af..713f359dc270 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -1,10 +1,10 @@ import Foundation +import OpenClawProtocol import Testing @testable import OpenClawKit -import OpenClawProtocol -private extension NSLock { - func withLock(_ body: () -> T) -> T { +extension NSLock { + fileprivate func withLock(_ body: () -> T) -> T { self.lock() defer { self.unlock() } return body() @@ -18,7 +18,9 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke self.callbacks = callbacks } - var state: URLSessionTask.State { .running } + var state: URLSessionTask.State { + .running + } func resume() {} @@ -53,6 +55,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda private var _state: URLSessionTask.State = .suspended private var connectRequestId: String? private var connectAuth: [String: Any]? + private var connectDevice: [String: Any]? private var receivePhase = 0 private var pendingReceiveHandler: (@Sendable (Result) -> Void)? @@ -73,7 +76,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { _ = (closeCode, reason) self.state = .canceling - let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in + let handler = self.lock.withLock { () -> (@Sendable (Result< + URLSessionWebSocketTask.Message, + Error, + >) -> Void)? in defer { self.pendingReceiveHandler = nil } return self.pendingReceiveHandler } @@ -92,10 +98,13 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda obj["method"] as? String == "connect", let id = obj["id"] as? String { - let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:] + 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 } } } @@ -104,6 +113,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda self.lock.withLock { self.connectAuth } } + func latestConnectDevice() -> [String: Any]? { + self.lock.withLock { self.connectDevice } + } + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { pongReceiveHandler(nil) } @@ -134,7 +147,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda } func emitReceiveFailure() { - let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in + let handler = self.lock.withLock { () -> (@Sendable (Result< + URLSessionWebSocketTask.Message, + Error, + >) -> Void)? in self._state = .canceling defer { self.pendingReceiveHandler = nil } return self.pendingReceiveHandler @@ -175,7 +191,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda "policy": [ "maxPayload": 1, "maxBufferedBytes": 1, - "tickIntervalMs": 30_000, + "tickIntervalMs": 30000, ], "auth": [:], ] @@ -223,20 +239,25 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked private actor SeqGapProbe { private var saw = false - func mark() { self.saw = true } - func value() -> Bool { self.saw } + func mark() { + self.saw = true + } + + func value() -> Bool { + self.saw + } } @Suite(.serialized) struct GatewayNodeSessionTests { @Test - func websocketPingIgnoresDuplicateSuccessCallbacks() async throws { + func `websocket ping ignores duplicate success callbacks`() async throws { let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil]) try await WebSocketTaskBox(task: task).sendPing() } @Test - func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws { + func `websocket ping ignores duplicate callbacks after first error`() async throws { let firstError = URLError(.networkConnectionLost) let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil]) @@ -249,7 +270,7 @@ struct GatewayNodeSessionTests { } @Test - func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws { + func `scanned setup code prefers bootstrap auth over stored device token`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -284,7 +305,7 @@ struct GatewayNodeSessionTests { includeDeviceIdentity: true) try await gateway.connect( - url: URL(string: "ws://example.invalid")!, + url: #require(URL(string: "ws://example.invalid")), token: nil, bootstrapToken: "fresh-bootstrap-token", password: nil, @@ -305,7 +326,74 @@ struct GatewayNodeSessionTests { } @Test - func passwordTakesPrecedenceOverBootstrapToken() async throws { + func `share extension identity profile uses separate node identity and token store`() 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 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")), + token: nil, + bootstrapToken: nil, + 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( @@ -320,7 +408,7 @@ struct GatewayNodeSessionTests { includeDeviceIdentity: false) try await gateway.connect( - url: URL(string: "ws://example.invalid")!, + url: #require(URL(string: "ws://example.invalid")), token: nil, bootstrapToken: "stale-bootstrap-token", password: "shared-password", @@ -341,7 +429,7 @@ struct GatewayNodeSessionTests { } @Test - func changedSessionBoxRebuildsExistingGatewayChannel() async throws { + func `changed session box rebuilds existing gateway channel`() async throws { let firstSession = FakeGatewayWebSocketSession() let secondSession = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() @@ -357,7 +445,7 @@ struct GatewayNodeSessionTests { includeDeviceIdentity: false) try await gateway.connect( - url: URL(string: "wss://example.invalid")!, + url: #require(URL(string: "wss://example.invalid")), token: "shared-token", bootstrapToken: nil, password: nil, @@ -370,7 +458,7 @@ struct GatewayNodeSessionTests { }) try await gateway.connect( - url: URL(string: "wss://example.invalid")!, + url: #require(URL(string: "wss://example.invalid")), token: "shared-token", bootstrapToken: nil, password: nil, @@ -389,7 +477,7 @@ struct GatewayNodeSessionTests { } @Test - func bootstrapHelloStoresAdditionalDeviceTokens() async throws { + func `bootstrap hello stores additional device tokens`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -440,7 +528,7 @@ struct GatewayNodeSessionTests { includeDeviceIdentity: true) try await gateway.connect( - url: URL(string: "wss://example.invalid")!, + url: #require(URL(string: "wss://example.invalid")), token: nil, bootstrapToken: "fresh-bootstrap-token", password: nil, @@ -468,7 +556,7 @@ struct GatewayNodeSessionTests { } @Test - func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws { + func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -509,7 +597,7 @@ struct GatewayNodeSessionTests { includeDeviceIdentity: true) try await gateway.connect( - url: URL(string: "wss://example.invalid")!, + url: #require(URL(string: "wss://example.invalid")), token: "shared-token", bootstrapToken: nil, password: nil, @@ -530,7 +618,7 @@ struct GatewayNodeSessionTests { } @Test - func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() async throws { + func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -574,7 +662,7 @@ struct GatewayNodeSessionTests { includeDeviceIdentity: true) try await gateway.connect( - url: URL(string: "ws://example.invalid")!, + url: #require(URL(string: "ws://example.invalid")), token: nil, bootstrapToken: "fresh-bootstrap-token", password: nil, @@ -593,25 +681,25 @@ struct GatewayNodeSessionTests { } @Test - func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() { - let normalized = canonicalizeCanvasHostUrl( + func `normalize canvas host url preserves explicit secure canvas port`() throws { + let normalized = try canonicalizeCanvasHostUrl( raw: "https://canvas.example.com:9443/__openclaw__/cap/token", - activeURL: URL(string: "wss://gateway.example.com")!) + activeURL: #require(URL(string: "wss://gateway.example.com"))) #expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token") } @Test - func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() { - let normalized = canonicalizeCanvasHostUrl( + func `normalize canvas host url backfills gateway host for loopback canvas`() throws { + let normalized = try canonicalizeCanvasHostUrl( raw: "http://127.0.0.1:18789/__openclaw__/cap/token", - activeURL: URL(string: "wss://gateway.example.com:7443")!) + activeURL: #require(URL(string: "wss://gateway.example.com:7443"))) #expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token") } @Test - func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async { + 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, @@ -619,8 +707,7 @@ struct GatewayNodeSessionTests { 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) @@ -628,7 +715,7 @@ struct GatewayNodeSessionTests { } @Test - func invokeWithTimeoutReturnsTimeoutError() async { + func `invoke with timeout returns timeout error`() async { let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil) let response = await GatewayNodeSession.invokeWithTimeout( request: request, @@ -636,8 +723,7 @@ struct GatewayNodeSessionTests { 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) @@ -645,7 +731,7 @@ struct GatewayNodeSessionTests { } @Test - func invokeWithTimeoutZeroDisablesTimeout() async { + func `invoke with timeout zero disables timeout`() async { let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) let response = await GatewayNodeSession.invokeWithTimeout( request: request, @@ -653,15 +739,14 @@ struct GatewayNodeSessionTests { 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 emitsSyntheticSeqGapAfterReconnectSnapshot() async throws { + func `emits synthetic seq gap after reconnect snapshot`() async throws { let session = FakeGatewayWebSocketSession() let gateway = GatewayNodeSession() let options = GatewayConnectOptions( @@ -687,7 +772,7 @@ struct GatewayNodeSessionTests { } try await gateway.connect( - url: URL(string: "ws://example.invalid")!, + url: #require(URL(string: "ws://example.invalid")), token: nil, bootstrapToken: nil, password: nil, diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts index c76df3e67a70..4db618a58149 100644 --- a/extensions/github-copilot/embeddings.test.ts +++ b/extensions/github-copilot/embeddings.test.ts @@ -47,6 +47,28 @@ function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: u return { data: models }; } +function cancelTrackedResponse( + text: string, + init: ResponseInit, +): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, init), + wasCanceled: () => canceled, + }; +} + function mockDiscoveryResponse(spec: { ok: boolean; status?: number; @@ -116,6 +138,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); resolveConfiguredSecretInputStringMock.mockReset(); resolveFirstGithubTokenMock.mockReset(); resolveCopilotApiTokenMock.mockReset(); @@ -221,6 +244,63 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { ).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON"); }); + it("bounds model discovery error bodies", async () => { + const tracked = cancelTrackedResponse(`${"discovery denied ".repeat(1024)}tail`, { + status: 503, + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({ + response: tracked.response, + release: vi.fn(async () => {}), + })); + + let caught: Error | undefined; + try { + await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()); + } catch (error) { + caught = error as Error; + } + + expect(caught?.message).toContain("GitHub Copilot model discovery HTTP 503"); + expect(caught?.message).toContain("discovery denied"); + expect(caught?.message).not.toContain("tail"); + expect(caught?.message.length).toBeLessThan(8_300); + expect(tracked.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + }); + + it("bounds embeddings error bodies", async () => { + mockDiscoveryResponse({ + ok: true, + json: buildModelsResponse([ + { id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] }, + ]), + }); + const tracked = cancelTrackedResponse(`${"embedding denied ".repeat(1024)}tail`, { + status: 429, + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + const fetchImpl = vi.fn(async () => tracked.response); + vi.stubGlobal("fetch", fetchImpl); + const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()); + + let caught: Error | undefined; + try { + await result.provider?.embedQuery("hello"); + } catch (error) { + caught = error as Error; + } + + expect(caught?.message).toContain("GitHub Copilot embeddings HTTP 429"); + expect(caught?.message).toContain("embedding denied"); + expect(caught?.message).not.toContain("tail"); + expect(caught?.message.length).toBeLessThan(8_300); + expect(tracked.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + }); + it("honors remote overrides when creating the provider", async () => { resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "gh_remote_token" }); mockDiscoveryResponse({ diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts index 1a6f4456a179..e682dfac22c3 100644 --- a/extensions/github-copilot/embeddings.ts +++ b/extensions/github-copilot/embeddings.ts @@ -7,6 +7,7 @@ import { type MemoryEmbeddingProviderAdapter, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth"; +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveFirstGithubToken } from "./auth.js"; @@ -27,6 +28,7 @@ const COPILOT_HEADERS_STATIC: Record = { "Content-Type": "application/json", ...buildCopilotIdeHeaders(), }; +const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024; function buildSsrfPolicy(baseUrl: string): SsrFPolicy | undefined { try { @@ -95,9 +97,8 @@ async function discoverEmbeddingModels(params: { }); try { if (!response.ok) { - throw new Error( - `GitHub Copilot model discovery HTTP ${response.status}: ${await response.text()}`, - ); + const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES); + throw new Error(`GitHub Copilot model discovery HTTP ${response.status}: ${detail}`); } let payload: unknown; try { @@ -241,9 +242,8 @@ async function createGitHubCopilotEmbeddingProvider( }, onResponse: async (response) => { if (!response.ok) { - throw new Error( - `GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`, - ); + const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES); + throw new Error(`GitHub Copilot embeddings HTTP ${response.status}: ${detail}`); } let payload: unknown; diff --git a/extensions/google-meet/src/calendar.ts b/extensions/google-meet/src/calendar.ts index 631e363dc0b7..38096b3715ff 100644 --- a/extensions/google-meet/src/calendar.ts +++ b/extensions/google-meet/src/calendar.ts @@ -191,10 +191,8 @@ async function fetchGoogleCalendarEvents(params: { }); try { if (!response.ok) { - const detail = await response.text(); throw await googleApiError({ response, - detail, prefix: "Google Calendar events.list", scopes: [GOOGLE_CALENDAR_EVENTS_SCOPE], }); diff --git a/extensions/google-meet/src/drive.ts b/extensions/google-meet/src/drive.ts index 266dc4d70df5..a47da9188247 100644 --- a/extensions/google-meet/src/drive.ts +++ b/extensions/google-meet/src/drive.ts @@ -58,10 +58,8 @@ export async function exportGoogleDriveDocumentText(params: { }); try { if (!response.ok) { - const detail = await response.text(); throw await googleApiError({ response, - detail, prefix: "Google Drive files.export", scopes: [GOOGLE_DRIVE_MEET_SCOPE], }); diff --git a/extensions/google-meet/src/google-api-errors.test.ts b/extensions/google-meet/src/google-api-errors.test.ts new file mode 100644 index 000000000000..90fcd6bb2a9e --- /dev/null +++ b/extensions/google-meet/src/google-api-errors.test.ts @@ -0,0 +1,47 @@ +// Google Meet tests cover bounded Google API error handling. +import { describe, expect, it, vi } from "vitest"; +import { googleApiError } from "./google-api-errors.js"; + +function cancelTrackedResponse( + text: string, + init: ResponseInit, +): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, init), + wasCanceled: () => canceled, + }; +} + +describe("googleApiError", () => { + it("bounds Google API error bodies without using response.text()", async () => { + const tracked = cancelTrackedResponse(`${"access denied ".repeat(1024)}tail`, { + status: 403, + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + + const error = await googleApiError({ + response: tracked.response, + prefix: "Google Meet spaces.get", + scopes: ["https://www.googleapis.com/auth/meetings.space.readonly"], + }); + + expect(error.message).toContain("Google Meet spaces.get failed (403): access denied"); + expect(error.message).not.toContain("tail"); + expect(error.message.length).toBeLessThan(8_400); + expect(tracked.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/google-meet/src/google-api-errors.ts b/extensions/google-meet/src/google-api-errors.ts index 99697c4f63fb..0d0b08f5e07e 100644 --- a/extensions/google-meet/src/google-api-errors.ts +++ b/extensions/google-meet/src/google-api-errors.ts @@ -1,21 +1,26 @@ // Google Meet plugin module implements google api errors behavior. +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; + const REAUTH_HINT = "Re-run `openclaw googlemeet auth login` and store the refreshed oauth block."; +const GOOGLE_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024; function scopeText(scopes: readonly string[]): string { return scopes.map((scope) => `\`${scope}\``).join(", "); } +export async function readGoogleApiErrorDetail(response: Response): Promise { + return await readResponseTextLimited(response, GOOGLE_API_ERROR_BODY_LIMIT_BYTES); +} + export async function googleApiError(params: { response: Response; - detail: string; prefix: string; scopes?: readonly string[]; }): Promise { + const detail = await readGoogleApiErrorDetail(params.response); const scopeHint = params.scopes && params.scopes.length > 0 ? ` Required OAuth scope: ${scopeText(params.scopes)}. ${REAUTH_HINT}` : ""; - return new Error( - `${params.prefix} failed (${params.response.status}): ${params.detail}${scopeHint}`, - ); + return new Error(`${params.prefix} failed (${params.response.status}): ${detail}${scopeHint}`); } diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 862ec38e22a3..e318e86193c7 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -283,10 +283,8 @@ async function fetchGoogleMeetJson(params: { }); try { if (!response.ok) { - const detail = await response.text(); throw await googleApiError({ response, - detail, prefix: params.errorPrefix, scopes: [GOOGLE_MEET_MEDIA_SCOPE], }); @@ -350,10 +348,8 @@ export async function fetchGoogleMeetSpace(params: { }); try { if (!response.ok) { - const detail = await response.text(); throw await googleApiError({ response, - detail, prefix: "Google Meet spaces.get", scopes: [GOOGLE_MEET_SPACE_SCOPE], }); @@ -392,10 +388,8 @@ export async function createGoogleMeetSpace(params: { }); try { if (!response.ok) { - const detail = await response.text(); throw await googleApiError({ response, - detail, prefix: "Google Meet spaces.create", scopes: params.config && Object.keys(params.config).length > 0 @@ -442,10 +436,8 @@ export async function endGoogleMeetActiveConference(params: { }); try { if (!response.ok) { - const detail = await response.text(); throw await googleApiError({ response, - detail, prefix: "Google Meet spaces.endActiveConference", scopes: [GOOGLE_MEET_SPACE_CREATED_SCOPE], }); diff --git a/extensions/google-meet/src/oauth.ts b/extensions/google-meet/src/oauth.ts index 2bc1a6ae9e63..8d829ec17b66 100644 --- a/extensions/google-meet/src/oauth.ts +++ b/extensions/google-meet/src/oauth.ts @@ -11,6 +11,7 @@ import { waitForLocalOAuthCallback, } from "openclaw/plugin-sdk/provider-auth-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { readGoogleApiErrorDetail } from "./google-api-errors.js"; const GOOGLE_MEET_REDIRECT_URI = "http://localhost:8085/oauth2callback"; const GOOGLE_MEET_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -85,7 +86,7 @@ async function executeGoogleTokenRequest(body: URLSearchParams): Promise, @@ -59,23 +56,6 @@ function expectAllowedApply( } describe("gateway config mutation guard coverage", () => { - it("keeps a narrow allowlist of agent-tunable config paths", () => { - // This list is the contract between the public gateway tool and protected - // operator-owned config surfaces. - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).not.toContain("agents.defaults.promptOverlays"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).not.toContain("agents.defaults.model"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.defaults.subagents.thinking"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.list[].id"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.list[].model"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("agents.list[].subagents.thinking"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("channels.*.requireMention"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("messages.visibleReplies"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain("messages.groupChat.visibleReplies"); - expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toContain( - "messages.groupChat.unmentionedInbound", - ); - }); - it("blocks global prompt overlay edits via config.patch", () => { expectBlocked( { agents: { defaults: { promptOverlays: { gpt5: { personality: "off" } } } } }, @@ -167,7 +147,10 @@ describe("gateway config mutation guard coverage", () => { { messages: { visibleReplies: "automatic", - groupChat: { visibleReplies: "automatic" }, + groupChat: { + visibleReplies: "automatic", + unmentionedInbound: "user_request", + }, }, }, ); @@ -181,7 +164,10 @@ describe("gateway config mutation guard coverage", () => { { messages: { visibleReplies: "message_tool", - groupChat: { visibleReplies: "automatic" }, + groupChat: { + visibleReplies: "automatic", + unmentionedInbound: "room_event", + }, }, }, ); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 42608ebddb35..1f4f0b5a6524 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -79,9 +79,6 @@ const ALLOWED_GATEWAY_CONFIG_PATHS = [ "messages.groupChat.unmentionedInbound", ] as const; -/** @internal Exposed for regression tests only; do not import from runtime code. */ -export const ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST = ALLOWED_GATEWAY_CONFIG_PATHS; - /** @internal Exposed for regression tests only; do not import from runtime code. */ export function assertGatewayConfigMutationAllowedForTest(params: { action: "config.apply" | "config.patch";