mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 10:34:44 +00:00
fix(channels): stop duplicating inbound previews in system events
This commit is contained in:
committed by
GitHub
parent
5e6aa61186
commit
981003591c
@@ -184,7 +184,8 @@ final class ShareViewController: UIViewController {
|
||||
clientId: clientId,
|
||||
clientMode: "node",
|
||||
clientDisplayName: "OpenClaw Share",
|
||||
includeDeviceIdentity: false)
|
||||
deviceIdentityProfile: .shareExtension,
|
||||
includeDeviceIdentity: true)
|
||||
}
|
||||
|
||||
do {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "|")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
private extension NSLock {
|
||||
func withLock<T>(_ body: () -> T) -> T {
|
||||
extension NSLock {
|
||||
fileprivate func withLock<T>(_ 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<URLSessionWebSocketTask.Message, Error>) -> 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<URLSessionWebSocketTask.Message, Error>) -> 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<URLSessionWebSocketTask.Message, Error>) -> 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,
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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({
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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;
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
47
extensions/google-meet/src/google-api-errors.test.ts
Normal file
47
extensions/google-meet/src/google-api-errors.test.ts
Normal file
@@ -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<Uint8Array>({
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<Error> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -283,10 +283,8 @@ async function fetchGoogleMeetJson<T>(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],
|
||||
});
|
||||
|
||||
@@ -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<GoogleM
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
const detail = await readGoogleApiErrorDetail(response);
|
||||
throw new Error(`Google OAuth token request failed (${response.status}): ${detail}`);
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// Gateway config mutation guard coverage keeps agent-driven config edits inside
|
||||
// the documented low-risk allowlist.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST,
|
||||
assertGatewayConfigMutationAllowedForTest,
|
||||
} from "./gateway-tool.js";
|
||||
import { assertGatewayConfigMutationAllowedForTest } from "./gateway-tool.js";
|
||||
|
||||
function expectBlocked(
|
||||
currentConfig: Record<string, unknown>,
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user