mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:41:37 +00:00
fix(mac): prevent app auth from blocking node-host startup (#115533)
* fix(mac): move device auth tokens to shared SQLite state * fix(mac): fail identity migration when source auth vanishes mid-read Missing-file tolerance now applies only to the first observation; a disappearance after the auth file was observed fails the migration so the claimed identity survives for retry instead of committing without its credentials. * fix(ios): validate auth scope UTF-8 encoding * fix(ios): isolate SQLite auth profiles * chore(ios): refresh native source inventory * fix(mac): keep device auth in the shared token table * test(mac): pin shared-key token cache semantics
This commit is contained in:
committed by
GitHub
parent
32e522e88b
commit
b2701ac9cf
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawNativeState
|
||||
|
||||
public struct DeviceAuthEntry: Codable, Sendable, Equatable {
|
||||
public let token: String
|
||||
@@ -23,15 +24,27 @@ struct DeviceAuthStoreFile: Codable, Equatable {
|
||||
}
|
||||
|
||||
public enum DeviceAuthStore {
|
||||
private typealias Database = OpenClawNativeStateSQLite
|
||||
|
||||
static let maximumLegacyAuthBytes = 4 * 1024 * 1024
|
||||
private static let busyTimeoutMilliseconds: Int32 = 30000
|
||||
|
||||
private enum LegacyImport {
|
||||
case missing
|
||||
case valid(DeviceAuthStoreFile)
|
||||
case invalid
|
||||
}
|
||||
|
||||
public static func loadToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
gatewayID: String? = nil,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
|
||||
{
|
||||
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
|
||||
guard let key = self.tokenKey(role: role, gatewayID: gatewayID) else { return nil }
|
||||
return store.tokens[key]
|
||||
return try? self.withStore(profile: profile) { database in
|
||||
try self.readEntry(database, deviceId: deviceId, storedRole: key)
|
||||
}
|
||||
}
|
||||
|
||||
public static func storeToken(
|
||||
@@ -83,21 +96,17 @@ public enum DeviceAuthStore {
|
||||
let entry = DeviceAuthEntry(
|
||||
token: token,
|
||||
role: normalizedRole,
|
||||
scopes: normalizeScopes(scopes),
|
||||
scopes: self.normalizeScopes(scopes),
|
||||
updatedAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
gatewayID: normalizedGatewayID)
|
||||
guard gatewayID == nil || normalizedGatewayID != nil,
|
||||
let key = self.tokenKey(role: normalizedRole, gatewayID: normalizedGatewayID)
|
||||
else { return (entry, false) }
|
||||
var next = self.readStore(profile: profile)
|
||||
if next?.deviceId != deviceId {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
if next == nil {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
next?.tokens[key] = entry
|
||||
let persisted = next.map { self.writeStore($0, profile: profile) } ?? false
|
||||
let persisted = (try? self.withStore(profile: profile) { database in
|
||||
// SQLite intentionally keeps tokens for multiple devices instead of replacing the old file owner.
|
||||
try self.upsertEntry(database, deviceId: deviceId, storedRole: key, entry: entry)
|
||||
return true
|
||||
}) ?? false
|
||||
return (entry, persisted)
|
||||
}
|
||||
|
||||
@@ -107,21 +116,36 @@ public enum DeviceAuthStore {
|
||||
gatewayID: String? = nil,
|
||||
profile: GatewayDeviceIdentityProfile = .primary)
|
||||
{
|
||||
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
if gatewayID == nil {
|
||||
store.tokens = store.tokens.filter { _, entry in
|
||||
self.normalizeRole(entry.role) != normalizedRole
|
||||
try? self.withStore(profile: profile) { database in
|
||||
for key in try self.storedRoles(database, deviceId: deviceId)
|
||||
where self.normalizeRole(self.decodeTokenKey(key).role) == normalizedRole
|
||||
{
|
||||
try self.deleteEntry(database, deviceId: deviceId, storedRole: key)
|
||||
}
|
||||
}
|
||||
} else if let key = self.tokenKey(role: normalizedRole, gatewayID: gatewayID) {
|
||||
try? self.withStore(profile: profile) { database in
|
||||
try self.deleteEntry(database, deviceId: deviceId, storedRole: key)
|
||||
}
|
||||
} else {
|
||||
guard let key = self.tokenKey(role: normalizedRole, gatewayID: gatewayID) else { return }
|
||||
store.tokens.removeValue(forKey: key)
|
||||
}
|
||||
self.writeStore(store, profile: profile)
|
||||
}
|
||||
|
||||
public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) {
|
||||
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
|
||||
let stateDirectoryURL = DeviceIdentityPaths.stateDirURL()
|
||||
let databaseURL = stateDirectoryURL
|
||||
.appendingPathComponent("state", isDirectory: true)
|
||||
.appendingPathComponent("openclaw.sqlite", isDirectory: false)
|
||||
guard let identity = try? DeviceIdentitySQLiteStore.loadExisting(
|
||||
databaseURL: databaseURL,
|
||||
profile: profile)
|
||||
else { return }
|
||||
try? self.withStore(profile: profile) { database in
|
||||
let statement = try database.prepare("DELETE FROM device_auth_tokens WHERE device_id = ?")
|
||||
try statement.bindText(identity.deviceId, at: 1)
|
||||
_ = try statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims one legacy role token for a caller-proven gateway identity.
|
||||
@@ -133,26 +157,30 @@ public enum DeviceAuthStore {
|
||||
toGatewayID gatewayID: String,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> Bool
|
||||
{
|
||||
guard let gatewayID = self.normalizeGatewayID(gatewayID),
|
||||
var store = self.readStore(profile: profile),
|
||||
store.deviceId == deviceId
|
||||
else { return false }
|
||||
|
||||
guard let gatewayID = self.normalizeGatewayID(gatewayID) else { return false }
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
guard let legacyKey = self.tokenKey(role: normalizedRole, gatewayID: nil),
|
||||
let scopedKey = self.tokenKey(role: normalizedRole, gatewayID: gatewayID)
|
||||
else { return false }
|
||||
guard let entry = store.tokens[legacyKey], entry.gatewayID == nil else { return false }
|
||||
if store.tokens[scopedKey] == nil {
|
||||
store.tokens[scopedKey] = DeviceAuthEntry(
|
||||
token: entry.token,
|
||||
role: normalizedRole,
|
||||
scopes: entry.scopes,
|
||||
updatedAtMs: entry.updatedAtMs,
|
||||
gatewayID: gatewayID)
|
||||
}
|
||||
store.tokens.removeValue(forKey: legacyKey)
|
||||
return self.writeStore(store, profile: profile)
|
||||
return (try? self.withStore(profile: profile) { database in
|
||||
guard let entry = try self.readEntry(database, deviceId: deviceId, storedRole: legacyKey),
|
||||
entry.gatewayID == nil
|
||||
else { return false }
|
||||
if try !self.storedRoles(database, deviceId: deviceId).contains(scopedKey) {
|
||||
try self.upsertEntry(
|
||||
database,
|
||||
deviceId: deviceId,
|
||||
storedRole: scopedKey,
|
||||
entry: DeviceAuthEntry(
|
||||
token: entry.token,
|
||||
role: normalizedRole,
|
||||
scopes: entry.scopes,
|
||||
updatedAtMs: entry.updatedAtMs,
|
||||
gatewayID: gatewayID))
|
||||
}
|
||||
try self.deleteEntry(database, deviceId: deviceId, storedRole: legacyKey)
|
||||
return true
|
||||
}) ?? false
|
||||
}
|
||||
|
||||
/// Removes legacy tokens when the app cannot prove which gateway issued them.
|
||||
@@ -161,13 +189,227 @@ public enum DeviceAuthStore {
|
||||
deviceId: String,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) -> Int
|
||||
{
|
||||
guard var store = self.readStore(profile: profile), store.deviceId == deviceId else { return 0 }
|
||||
let legacyKeys = store.tokens.compactMap { key, entry in entry.gatewayID == nil ? key : nil }
|
||||
guard !legacyKeys.isEmpty else { return 0 }
|
||||
for key in legacyKeys {
|
||||
store.tokens.removeValue(forKey: key)
|
||||
(try? self.withStore(profile: profile) { database in
|
||||
let keys = try self.storedRoles(database, deviceId: deviceId)
|
||||
.filter { self.decodeTokenKey($0).gatewayID == nil }
|
||||
for key in keys {
|
||||
try self.deleteEntry(database, deviceId: deviceId, storedRole: key)
|
||||
}
|
||||
return keys.count
|
||||
}) ?? 0
|
||||
}
|
||||
|
||||
static func importLegacyStore(
|
||||
_ store: DeviceAuthStoreFile,
|
||||
stateDirectoryURL: URL,
|
||||
profile: GatewayDeviceIdentityProfile) throws
|
||||
{
|
||||
try self.withStore(stateDirectoryURL: stateDirectoryURL, profile: profile) { database in
|
||||
try self.importLegacyStore(store, into: database)
|
||||
}
|
||||
return self.writeStore(store, profile: profile) ? legacyKeys.count : 0
|
||||
}
|
||||
|
||||
static func normalizedStore(_ decoded: DeviceAuthStoreFile) -> DeviceAuthStoreFile? {
|
||||
guard decoded.version == 1 else { return nil }
|
||||
// Entries carry their owner, so migration compares one canonical role/scope/owner map
|
||||
// instead of depending on raw JSON key order or obsolete dictionary keys.
|
||||
var tokens: [String: DeviceAuthEntry] = [:]
|
||||
for entry in decoded.tokens.values {
|
||||
let role = self.normalizeRole(entry.role)
|
||||
let gatewayID = self.normalizeGatewayID(entry.gatewayID)
|
||||
guard entry.gatewayID == nil || gatewayID != nil,
|
||||
let key = self.tokenKey(role: role, gatewayID: gatewayID)
|
||||
else { continue }
|
||||
let normalized = DeviceAuthEntry(
|
||||
token: entry.token,
|
||||
role: role,
|
||||
scopes: self.normalizeScopes(entry.scopes),
|
||||
updatedAtMs: entry.updatedAtMs,
|
||||
gatewayID: gatewayID)
|
||||
if let existing = tokens[key] {
|
||||
if existing.updatedAtMs > normalized.updatedAtMs { continue }
|
||||
if existing.updatedAtMs == normalized.updatedAtMs, existing != normalized { return nil }
|
||||
}
|
||||
tokens[key] = normalized
|
||||
}
|
||||
return DeviceAuthStoreFile(version: 1, deviceId: decoded.deviceId, tokens: tokens)
|
||||
}
|
||||
|
||||
private static func withStore<Value>(
|
||||
profile: GatewayDeviceIdentityProfile,
|
||||
_ body: (Database) throws -> Value) throws -> Value
|
||||
{
|
||||
try self.withStore(
|
||||
stateDirectoryURL: DeviceIdentityPaths.stateDirURL(),
|
||||
profile: profile,
|
||||
body)
|
||||
}
|
||||
|
||||
private static func withStore<Value>(
|
||||
stateDirectoryURL: URL,
|
||||
profile: GatewayDeviceIdentityProfile,
|
||||
_ body: (Database) throws -> Value) throws -> Value
|
||||
{
|
||||
let legacyURL = self.legacyFileURL(stateDirectoryURL: stateDirectoryURL, profile: profile)
|
||||
let legacy = try self.readLegacyStore(legacyURL)
|
||||
if case .invalid = legacy {
|
||||
try self.quarantineInvalidLegacyFile(legacyURL)
|
||||
}
|
||||
let database = try self.openDatabase(stateDirectoryURL: stateDirectoryURL)
|
||||
if case let .valid(store) = legacy {
|
||||
try database.withImmediateTransaction {
|
||||
try database.ensureCanonicalTable(.deviceAuthTokens)
|
||||
try self.importLegacyStore(store, into: database)
|
||||
}
|
||||
try self.removeLegacyFile(legacyURL)
|
||||
}
|
||||
// Retire legacy state before a clear can commit; otherwise a crash could reimport a revoked row.
|
||||
return try database.withImmediateTransaction {
|
||||
try database.ensureCanonicalTable(.deviceAuthTokens)
|
||||
return try body(database)
|
||||
}
|
||||
}
|
||||
|
||||
private static func openDatabase(stateDirectoryURL: URL) throws -> Database {
|
||||
try Database(
|
||||
databaseURL: stateDirectoryURL
|
||||
.appendingPathComponent("state", isDirectory: true)
|
||||
.appendingPathComponent("openclaw.sqlite", isDirectory: false),
|
||||
busyTimeoutMilliseconds: self.busyTimeoutMilliseconds)
|
||||
}
|
||||
|
||||
private static func importLegacyStore(
|
||||
_ store: DeviceAuthStoreFile,
|
||||
into database: Database) throws
|
||||
{
|
||||
for (key, entry) in store.tokens.sorted(by: { $0.key < $1.key }) {
|
||||
if try self.rowIsCanonical(database, deviceId: store.deviceId, storedRole: key) {
|
||||
continue
|
||||
}
|
||||
try self.upsertEntry(database, deviceId: store.deviceId, storedRole: key, entry: entry)
|
||||
}
|
||||
}
|
||||
|
||||
private static func readEntry(
|
||||
_ database: Database,
|
||||
deviceId: String,
|
||||
storedRole: String) throws -> DeviceAuthEntry?
|
||||
{
|
||||
let statement = try database.prepare("""
|
||||
SELECT token, scopes_json, updated_at_ms
|
||||
FROM device_auth_tokens
|
||||
WHERE device_id = ? AND role = ?
|
||||
""")
|
||||
try statement.bindText(deviceId, at: 1)
|
||||
try statement.bindText(storedRole, at: 2)
|
||||
guard try statement.step() == .row else { return nil }
|
||||
let token = try statement.requiredText(at: 0, field: "device auth token")
|
||||
let scopesJSON = try statement.requiredText(at: 1, field: "device auth scopes_json")
|
||||
let updatedAtMs = statement.int64(at: 2)
|
||||
guard statement.valueType(at: 2) == .integer,
|
||||
let scopes = self.decodeScopes(scopesJSON)
|
||||
else { return nil }
|
||||
let decoded = self.decodeTokenKey(storedRole)
|
||||
return DeviceAuthEntry(
|
||||
token: token,
|
||||
role: decoded.role,
|
||||
scopes: scopes,
|
||||
updatedAtMs: updatedAtMs,
|
||||
gatewayID: decoded.gatewayID)
|
||||
}
|
||||
|
||||
private static func upsertEntry(
|
||||
_ database: Database,
|
||||
deviceId: String,
|
||||
storedRole: String,
|
||||
entry: DeviceAuthEntry) throws
|
||||
{
|
||||
let scopesData = try JSONEncoder().encode(self.normalizeScopes(entry.scopes))
|
||||
guard let scopes = String(bytes: scopesData, encoding: .utf8) else {
|
||||
throw OpenClawNativeStateError("failed to encode device auth scopes as UTF-8")
|
||||
}
|
||||
let statement = try database.prepare("""
|
||||
INSERT INTO device_auth_tokens (device_id, role, token, scopes_json, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_id, role) DO UPDATE SET
|
||||
token = excluded.token,
|
||||
scopes_json = excluded.scopes_json,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
""")
|
||||
try statement.bindText(deviceId, at: 1)
|
||||
try statement.bindText(storedRole, at: 2)
|
||||
try statement.bindText(entry.token, at: 3)
|
||||
try statement.bindText(scopes, at: 4)
|
||||
try statement.bindInt64(entry.updatedAtMs, at: 5)
|
||||
_ = try statement.step()
|
||||
}
|
||||
|
||||
private static func storedRoles(
|
||||
_ database: Database,
|
||||
deviceId: String) throws -> [String]
|
||||
{
|
||||
let statement = try database.prepare(
|
||||
"SELECT role FROM device_auth_tokens WHERE device_id = ? ORDER BY role")
|
||||
try statement.bindText(deviceId, at: 1)
|
||||
var keys: [String] = []
|
||||
while try statement.step() == .row {
|
||||
try keys.append(statement.requiredText(at: 0, field: "device auth role"))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
private static func rowIsCanonical(
|
||||
_ database: Database,
|
||||
deviceId: String,
|
||||
storedRole: String) throws -> Bool
|
||||
{
|
||||
let statement = try database.prepare(
|
||||
"SELECT scopes_json FROM device_auth_tokens WHERE device_id = ? AND role = ?")
|
||||
try statement.bindText(deviceId, at: 1)
|
||||
try statement.bindText(storedRole, at: 2)
|
||||
guard try statement.step() == .row else { return false }
|
||||
let scopesJSON = try statement.requiredText(at: 0, field: "device auth scopes_json")
|
||||
return self.jsonArray(scopesJSON) != nil
|
||||
}
|
||||
|
||||
private static func deleteEntry(
|
||||
_ database: Database,
|
||||
deviceId: String,
|
||||
storedRole: String) throws
|
||||
{
|
||||
let statement = try database.prepare(
|
||||
"DELETE FROM device_auth_tokens WHERE device_id = ? AND role = ?")
|
||||
try statement.bindText(deviceId, at: 1)
|
||||
try statement.bindText(storedRole, at: 2)
|
||||
_ = try statement.step()
|
||||
}
|
||||
|
||||
private static func decodeScopes(_ rawJSON: String) -> [String]? {
|
||||
guard let values = self.jsonArray(rawJSON) else { return nil }
|
||||
return self.normalizeScopes(values.compactMap { $0 as? String })
|
||||
}
|
||||
|
||||
private static func jsonArray(_ rawJSON: String) -> [Any]? {
|
||||
guard let data = rawJSON.data(using: .utf8) else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
}
|
||||
|
||||
private static func decodeTokenKey(_ key: String) -> (role: String, gatewayID: String?) {
|
||||
let parts = key.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 3, parts[0] == "v2",
|
||||
let gatewayID = self.decodeStorageComponent(String(parts[1])),
|
||||
let role = self.decodeStorageComponent(String(parts[2]))
|
||||
else { return (key, nil) }
|
||||
return (role, gatewayID)
|
||||
}
|
||||
|
||||
private static func decodeStorageComponent(_ value: String) -> String? {
|
||||
let base64 = value
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padded = base64 + String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func normalizeRole(_ role: String) -> String {
|
||||
@@ -203,67 +445,65 @@ public enum DeviceAuthStore {
|
||||
return Array(Set(trimmed)).sorted()
|
||||
}
|
||||
|
||||
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
private static func legacyFileURL(
|
||||
stateDirectoryURL: URL,
|
||||
profile: GatewayDeviceIdentityProfile) -> URL
|
||||
{
|
||||
stateDirectoryURL
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(profile.authFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return self.normalizedStore(decoded)
|
||||
}
|
||||
|
||||
static func normalizedStore(_ decoded: DeviceAuthStoreFile) -> DeviceAuthStoreFile? {
|
||||
guard decoded.version == 1 else { return nil }
|
||||
// Entries carry their owner, so reads and identity migration compare one canonical
|
||||
// role/scope/owner map instead of raw JSON key order or legacy dictionary keys.
|
||||
var tokens: [String: DeviceAuthEntry] = [:]
|
||||
for entry in decoded.tokens.values {
|
||||
let role = self.normalizeRole(entry.role)
|
||||
let gatewayID = self.normalizeGatewayID(entry.gatewayID)
|
||||
guard entry.gatewayID == nil || gatewayID != nil,
|
||||
let key = self.tokenKey(role: role, gatewayID: gatewayID)
|
||||
else { continue }
|
||||
let normalized = DeviceAuthEntry(
|
||||
token: entry.token,
|
||||
role: role,
|
||||
scopes: self.normalizeScopes(entry.scopes),
|
||||
updatedAtMs: entry.updatedAtMs,
|
||||
gatewayID: gatewayID)
|
||||
if let existing = tokens[key] {
|
||||
if existing.updatedAtMs > normalized.updatedAtMs {
|
||||
continue
|
||||
}
|
||||
if existing.updatedAtMs == normalized.updatedAtMs, existing != normalized {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
tokens[key] = normalized
|
||||
}
|
||||
return DeviceAuthStoreFile(version: 1, deviceId: decoded.deviceId, tokens: tokens)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func writeStore(
|
||||
_ store: DeviceAuthStoreFile,
|
||||
profile: GatewayDeviceIdentityProfile) -> Bool
|
||||
{
|
||||
let url = self.fileURL(profile: profile)
|
||||
private static func readLegacyStore(_ url: URL) throws -> LegacyImport {
|
||||
let attributes: [FileAttributeKey: Any]
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(store)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
} catch where self.isMissingFileError(error) {
|
||||
return .missing
|
||||
}
|
||||
guard let size = attributes[.size] as? NSNumber,
|
||||
size.intValue <= self.maximumLegacyAuthBytes,
|
||||
attributes[.type] as? FileAttributeType == .typeRegular
|
||||
else { return .invalid }
|
||||
do {
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
let data = try handle.read(upToCount: self.maximumLegacyAuthBytes + 1) ?? Data()
|
||||
guard data.count <= self.maximumLegacyAuthBytes,
|
||||
let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data),
|
||||
let normalized = self.normalizedStore(decoded)
|
||||
else { return .invalid }
|
||||
return .valid(normalized)
|
||||
} catch where self.isMissingFileError(error) {
|
||||
return .missing
|
||||
}
|
||||
}
|
||||
|
||||
private static func quarantineInvalidLegacyFile(_ url: URL) throws {
|
||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let destinationURL = URL(
|
||||
fileURLWithPath: "\(url.path).invalid-\(timestamp)",
|
||||
isDirectory: false)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: url, to: destinationURL)
|
||||
} catch where self.isMissingFileError(error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private static func removeLegacyFile(_ url: URL) throws {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch where self.isMissingFileError(error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
static func isMissingFileError(_ error: Error) -> Bool {
|
||||
let error = error as NSError
|
||||
return (error.domain == NSCocoaErrorDomain
|
||||
&& (error.code == NSFileNoSuchFileError || error.code == NSFileReadNoSuchFileError))
|
||||
|| (error.domain == NSPOSIXErrorDomain
|
||||
&& error.code == Int(POSIXErrorCode.ENOENT.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ enum DeviceIdentitySQLiteStore {
|
||||
// production access never waits this long.
|
||||
private static let busyTimeoutMilliseconds: Int32 = 30000
|
||||
private static let maximumLegacyIdentityBytes = 64 * 1024
|
||||
private static let maximumLegacyAuthBytes = 4 * 1024 * 1024
|
||||
private static let doctorClaimSuffix = ".doctor-importing"
|
||||
private static let nativeClaimSuffix = ".native-importing"
|
||||
|
||||
@@ -31,7 +30,6 @@ enum DeviceIdentitySQLiteStore {
|
||||
}
|
||||
|
||||
private struct LegacyAuthCandidate {
|
||||
let data: Data
|
||||
let store: DeviceAuthStoreFile
|
||||
}
|
||||
|
||||
@@ -89,6 +87,18 @@ enum DeviceIdentitySQLiteStore {
|
||||
}
|
||||
}
|
||||
|
||||
static func loadExisting(
|
||||
databaseURL: URL,
|
||||
profile: GatewayDeviceIdentityProfile) throws -> DeviceIdentity?
|
||||
{
|
||||
let database = try OpenClawNativeStateSQLite(
|
||||
databaseURL: databaseURL,
|
||||
createIfMissing: false)
|
||||
guard try database.schemaObjectExists(type: "table", name: "device_identities") else { return nil }
|
||||
try database.ensureCanonicalTable(.deviceIdentities, allowVersionZeroCreation: false)
|
||||
return try self.readIdentity(database, key: profile.rawValue)?.identity
|
||||
}
|
||||
|
||||
private static func loadOrCreateOwned(
|
||||
databaseURL: URL,
|
||||
destinationStateDirURL: URL,
|
||||
@@ -600,16 +610,11 @@ enum DeviceIdentitySQLiteStore {
|
||||
profile: GatewayDeviceIdentityProfile,
|
||||
deviceId: String) throws
|
||||
{
|
||||
let fileManager = FileManager.default
|
||||
let destinationIdentityDirURL = destinationStateDirURL
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
let destinationAuthURL = destinationIdentityDirURL
|
||||
.appendingPathComponent(profile.authFileName, isDirectory: false)
|
||||
let sourceAuth = try claims.compactMap { claim -> LegacyAuthCandidate? in
|
||||
let source = claim.source
|
||||
guard source.stateDirURL.standardizedFileURL != destinationStateDirURL.standardizedFileURL,
|
||||
fileManager.fileExists(atPath: source.authURL.path)
|
||||
else { return nil }
|
||||
guard source.stateDirURL.standardizedFileURL != destinationStateDirURL.standardizedFileURL else {
|
||||
return nil
|
||||
}
|
||||
return try self.readDeviceAuth(
|
||||
source.authURL,
|
||||
beneath: source.stateDirURL,
|
||||
@@ -621,80 +626,53 @@ enum DeviceIdentitySQLiteStore {
|
||||
throw DeviceIdentityStore.storageError(
|
||||
"Legacy device auth sources conflict; all identity sources preserved")
|
||||
}
|
||||
if fileManager.fileExists(atPath: destinationAuthURL.path) {
|
||||
let destinationAuth = try self.readDeviceAuth(
|
||||
destinationAuthURL,
|
||||
beneath: destinationStateDirURL,
|
||||
deviceId: deviceId)
|
||||
guard sourceAuth.allSatisfy({ $0.store == destinationAuth.store }) else {
|
||||
throw DeviceIdentityStore.storageError(
|
||||
"Destination device auth differs from legacy auth; identity source preserved")
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let selectedAuth = sourceAuth.first else { return }
|
||||
|
||||
// DeviceAuthStore remains file-backed. Copy it when identity ownership moves between
|
||||
// Apple containers, but never delete or rewrite the source auth file.
|
||||
try self.secureDirectory(destinationIdentityDirURL)
|
||||
let temporaryAuthURL = destinationIdentityDirURL.appendingPathComponent(
|
||||
".\(profile.authFileName).identity-migrating-\(UUID().uuidString)",
|
||||
isDirectory: false)
|
||||
defer { try? fileManager.removeItem(at: temporaryAuthURL) }
|
||||
try selectedAuth.data.write(to: temporaryAuthURL, options: [.atomic])
|
||||
try self.secureFile(temporaryAuthURL)
|
||||
|
||||
// Publish only complete bytes, and never replace a token another process won first.
|
||||
// Foundation rejects atomic + withoutOverwriting, so use Darwin's exclusive rename.
|
||||
let renameResult = temporaryAuthURL.path.withCString { sourcePath in
|
||||
destinationAuthURL.path.withCString { destinationPath in
|
||||
renamex_np(sourcePath, destinationPath, UInt32(RENAME_EXCL))
|
||||
}
|
||||
}
|
||||
if renameResult != 0 {
|
||||
let renameError = errno
|
||||
guard renameError == EEXIST else {
|
||||
throw DeviceIdentityStore.storageError(
|
||||
"Could not publish migrated device auth: \(String(cString: strerror(renameError)))")
|
||||
}
|
||||
let destinationAuth = try self.readDeviceAuth(
|
||||
destinationAuthURL,
|
||||
beneath: destinationStateDirURL,
|
||||
deviceId: deviceId)
|
||||
guard destinationAuth.store == selectedAuth.store else {
|
||||
throw DeviceIdentityStore.storageError(
|
||||
"Concurrently created device auth differs from legacy auth; identity source preserved")
|
||||
}
|
||||
return
|
||||
}
|
||||
try self.secureFile(destinationAuthURL)
|
||||
// Cross-container auth remains at its source; only canonical SQLite rows move.
|
||||
try DeviceAuthStore.importLegacyStore(
|
||||
selectedAuth.store,
|
||||
stateDirectoryURL: destinationStateDirURL,
|
||||
profile: profile)
|
||||
}
|
||||
|
||||
private static func readDeviceAuth(
|
||||
_ url: URL,
|
||||
beneath stateDirURL: URL,
|
||||
deviceId: String) throws -> LegacyAuthCandidate
|
||||
deviceId: String) throws -> LegacyAuthCandidate?
|
||||
{
|
||||
let before = try self.legacyFileSnapshot(
|
||||
url,
|
||||
beneath: stateDirURL,
|
||||
maximumBytes: self.maximumLegacyAuthBytes)
|
||||
let data = try Data(contentsOf: url, options: [.mappedIfSafe])
|
||||
let after = try self.legacyFileSnapshot(
|
||||
url,
|
||||
beneath: stateDirURL,
|
||||
maximumBytes: self.maximumLegacyAuthBytes)
|
||||
guard before == after, UInt64(data.count) == before.size else {
|
||||
let before: LegacyFileSnapshot
|
||||
do {
|
||||
before = try self.legacyFileSnapshot(
|
||||
url,
|
||||
beneath: stateDirURL,
|
||||
maximumBytes: DeviceAuthStore.maximumLegacyAuthBytes)
|
||||
} catch where DeviceAuthStore.isMissingFileError(error) {
|
||||
// Absent at first observation means nothing to migrate. A disappearance after
|
||||
// this point must fail the migration instead, so the claimed identity survives
|
||||
// for retry rather than committing without its credentials.
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
let data = try handle.read(upToCount: DeviceAuthStore.maximumLegacyAuthBytes + 1) ?? Data()
|
||||
let after = try self.legacyFileSnapshot(
|
||||
url,
|
||||
beneath: stateDirURL,
|
||||
maximumBytes: DeviceAuthStore.maximumLegacyAuthBytes)
|
||||
guard before == after, UInt64(data.count) == before.size else {
|
||||
throw DeviceIdentityStore.storageError("Device auth changed during identity migration")
|
||||
}
|
||||
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data),
|
||||
let normalized = DeviceAuthStore.normalizedStore(decoded),
|
||||
normalized.deviceId == deviceId
|
||||
else {
|
||||
throw DeviceIdentityStore.storageError(
|
||||
"Device auth does not belong to the migrated device identity; source preserved")
|
||||
}
|
||||
return LegacyAuthCandidate(store: normalized)
|
||||
} catch where DeviceAuthStore.isMissingFileError(error) {
|
||||
throw DeviceIdentityStore.storageError("Device auth changed during identity migration")
|
||||
}
|
||||
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data),
|
||||
let normalized = DeviceAuthStore.normalizedStore(decoded),
|
||||
normalized.deviceId == deviceId
|
||||
else {
|
||||
throw DeviceIdentityStore.storageError(
|
||||
"Device auth does not belong to the migrated device identity; source preserved")
|
||||
}
|
||||
return LegacyAuthCandidate(data: data, store: normalized)
|
||||
}
|
||||
|
||||
private static func removeClaimedLegacyIdentities(_ claims: [LegacyClaim]) throws {
|
||||
|
||||
@@ -14,6 +14,7 @@ public struct OpenClawNativeStateError: Error, LocalizedError, Sendable {
|
||||
}
|
||||
|
||||
public enum OpenClawNativeStateCanonicalTable: Sendable {
|
||||
case deviceAuthTokens
|
||||
case deviceIdentities
|
||||
case execApprovalsConfig
|
||||
case macosPortGuardianRecords
|
||||
@@ -102,6 +103,35 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable {
|
||||
IndexColumn(name: "updated_at_ms", descending: true),
|
||||
])
|
||||
|
||||
private static let deviceAuthTokens = CanonicalTable(
|
||||
name: "device_auth_tokens",
|
||||
indexName: "idx_device_auth_tokens_updated",
|
||||
createSQL: """
|
||||
CREATE TABLE IF NOT EXISTS device_auth_tokens (
|
||||
device_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
scopes_json TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (device_id, role)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated
|
||||
ON device_auth_tokens(updated_at_ms DESC, device_id, role);
|
||||
""",
|
||||
columns: [
|
||||
Column(name: "device_id", type: "TEXT", notNull: true, primaryKeyPosition: 1, hidden: 0),
|
||||
Column(name: "role", type: "TEXT", notNull: true, primaryKeyPosition: 2, hidden: 0),
|
||||
Column(name: "token", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
||||
Column(name: "scopes_json", type: "TEXT", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
||||
Column(name: "updated_at_ms", type: "INTEGER", notNull: true, primaryKeyPosition: 0, hidden: 0),
|
||||
],
|
||||
indexColumns: [
|
||||
IndexColumn(name: "updated_at_ms", descending: true),
|
||||
IndexColumn(name: "device_id", descending: false),
|
||||
IndexColumn(name: "role", descending: false),
|
||||
])
|
||||
|
||||
private static let macosPortGuardianRecords = CanonicalTable(
|
||||
name: "macos_port_guardian_records",
|
||||
indexName: "idx_macos_port_guardian_records_port",
|
||||
@@ -163,6 +193,7 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable {
|
||||
indexColumns: [])
|
||||
|
||||
private static let canonicalTables = [
|
||||
OpenClawNativeStateSQLite.deviceAuthTokens,
|
||||
OpenClawNativeStateSQLite.deviceIdentities,
|
||||
OpenClawNativeStateSQLite.execApprovalsConfig,
|
||||
OpenClawNativeStateSQLite.macosPortGuardianRecords,
|
||||
@@ -172,11 +203,18 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable {
|
||||
fileprivate let database: OpaquePointer
|
||||
fileprivate let connectionLock = NSRecursiveLock()
|
||||
|
||||
public init(databaseURL: URL, busyTimeoutMilliseconds: Int32 = 5000) throws {
|
||||
public init(
|
||||
databaseURL: URL,
|
||||
busyTimeoutMilliseconds: Int32 = 5000,
|
||||
createIfMissing: Bool = true) throws
|
||||
{
|
||||
self.databaseURL = databaseURL
|
||||
try Self.secureDirectory(databaseURL.deletingLastPathComponent())
|
||||
if createIfMissing {
|
||||
try Self.secureDirectory(databaseURL.deletingLastPathComponent())
|
||||
}
|
||||
var database: OpaquePointer?
|
||||
let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
|
||||
let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
|
||||
| (createIfMissing ? SQLITE_OPEN_CREATE : 0)
|
||||
let result = sqlite3_open_v2(databaseURL.path, &database, flags, nil)
|
||||
guard result == SQLITE_OK, let database else {
|
||||
let detail = database.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown SQLite error"
|
||||
@@ -346,6 +384,7 @@ public final class OpenClawNativeStateSQLite: @unchecked Sendable {
|
||||
|
||||
private static func descriptor(_ table: OpenClawNativeStateCanonicalTable) -> CanonicalTable {
|
||||
switch table {
|
||||
case .deviceAuthTokens: self.deviceAuthTokens
|
||||
case .deviceIdentities: self.deviceIdentities
|
||||
case .execApprovalsConfig: self.execApprovalsConfig
|
||||
case .macosPortGuardianRecords: self.macosPortGuardianRecords
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
@Suite(.serialized)
|
||||
struct DeviceAuthStoreTests {
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `store and load round trip without legacy files`() throws {
|
||||
let deviceID = "device-round-trip"
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: deviceID,
|
||||
role: " node ",
|
||||
token: "unscoped-token",
|
||||
scopes: [" write ", "read", "write", " "]))
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: deviceID,
|
||||
role: "operator",
|
||||
token: "scoped-token",
|
||||
scopes: ["zeta", "alpha"],
|
||||
gatewayID: "gateway-a"))
|
||||
|
||||
#expect(try DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == DeviceAuthEntry(
|
||||
token: "unscoped-token",
|
||||
role: "node",
|
||||
scopes: ["read", "write"],
|
||||
updatedAtMs: #require(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.updatedAtMs)))
|
||||
let scoped = try #require(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "operator",
|
||||
gatewayID: "gateway-a"))
|
||||
#expect(scoped.role == "operator")
|
||||
#expect(scoped.gatewayID == "gateway-a")
|
||||
#expect(scoped.scopes == ["alpha", "zeta"])
|
||||
|
||||
for profile in [
|
||||
GatewayDeviceIdentityProfile.primary,
|
||||
.node,
|
||||
.shareExtension,
|
||||
] {
|
||||
#expect(try !FileManager.default.fileExists(atPath: Self.authURL(profile: profile).path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `same device id across profiles shares the token cache`() {
|
||||
let deviceID = "shared-device-id"
|
||||
// Matching device IDs imply matching key material and therefore one gateway device,
|
||||
// so profiles share this cache. The Node runtime reads device_auth_tokens only.
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
token: "primary-token",
|
||||
profile: .primary))
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
profile: .node)?.token == "primary-token")
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `distinct device ids remain disjoint across profiles`() {
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: "primary-device",
|
||||
role: "node",
|
||||
token: "primary-token",
|
||||
profile: .primary))
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: "node-device",
|
||||
role: "node",
|
||||
profile: .node) == nil)
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: "node-device",
|
||||
role: "node",
|
||||
token: "node-token",
|
||||
profile: .node))
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: "primary-device",
|
||||
role: "node",
|
||||
profile: .primary)?.token == "primary-token")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: "node-device",
|
||||
role: "node",
|
||||
profile: .node)?.token == "node-token")
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `legacy file imports once and reconstructs scoped metadata`() throws {
|
||||
let deviceID = "legacy-device"
|
||||
let gatewayID = "gateway-a"
|
||||
let scopedKey = "v2.\(Self.storageComponent(gatewayID)).\(Self.storageComponent("operator"))"
|
||||
try Self.writeLegacy(DeviceAuthStoreFile(
|
||||
version: 1,
|
||||
deviceId: deviceID,
|
||||
tokens: [
|
||||
"node": DeviceAuthEntry(
|
||||
token: "legacy-node",
|
||||
role: "node",
|
||||
scopes: [" beta ", "alpha"],
|
||||
updatedAtMs: 100),
|
||||
scopedKey: DeviceAuthEntry(
|
||||
token: "legacy-operator",
|
||||
role: "operator",
|
||||
scopes: ["write"],
|
||||
updatedAtMs: 200,
|
||||
gatewayID: gatewayID),
|
||||
]))
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-node")
|
||||
#expect(try !FileManager.default.fileExists(atPath: Self.authURL().path))
|
||||
let scoped = try #require(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "operator",
|
||||
gatewayID: gatewayID))
|
||||
#expect(scoped.token == "legacy-operator")
|
||||
#expect(scoped.role == "operator")
|
||||
#expect(scoped.gatewayID == gatewayID)
|
||||
#expect(scoped.scopes == ["write"])
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.scopes == ["alpha", "beta"])
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `legacy import preserves a canonical SQLite row`() throws {
|
||||
let deviceID = "preserve-device"
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
token: "sqlite-token",
|
||||
scopes: ["sqlite-scope"]))
|
||||
try Self.writeLegacy(DeviceAuthStoreFile(
|
||||
version: 1,
|
||||
deviceId: deviceID,
|
||||
tokens: [
|
||||
"node": DeviceAuthEntry(
|
||||
token: "legacy-token",
|
||||
role: "node",
|
||||
scopes: ["legacy-scope"],
|
||||
updatedAtMs: 1),
|
||||
]))
|
||||
|
||||
let loaded = try #require(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node"))
|
||||
#expect(loaded.token == "sqlite-token")
|
||||
#expect(loaded.scopes == ["sqlite-scope"])
|
||||
#expect(try !FileManager.default.fileExists(atPath: Self.authURL().path))
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `failed legacy removal cannot commit a clear`() throws {
|
||||
let deviceID = "removal-failure-device"
|
||||
try Self.writeLegacy(DeviceAuthStoreFile(
|
||||
version: 1,
|
||||
deviceId: deviceID,
|
||||
tokens: [
|
||||
"node": DeviceAuthEntry(
|
||||
token: "legacy-token",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
updatedAtMs: 100),
|
||||
]))
|
||||
let authURL = try Self.authURL()
|
||||
let identityDirectory = authURL.deletingLastPathComponent()
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: identityDirectory.path)
|
||||
defer {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path)
|
||||
}
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node")
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: authURL.path))
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-token")
|
||||
#expect(!FileManager.default.fileExists(atPath: authURL.path))
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `temporary legacy access failure remains retryable`() throws {
|
||||
let deviceID = "access-failure-device"
|
||||
try Self.writeLegacy(DeviceAuthStoreFile(
|
||||
version: 1,
|
||||
deviceId: deviceID,
|
||||
tokens: [
|
||||
"node": DeviceAuthEntry(
|
||||
token: "legacy-token",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
updatedAtMs: 100),
|
||||
]))
|
||||
let authURL = try Self.authURL()
|
||||
let identityDirectory = authURL.deletingLastPathComponent()
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: identityDirectory.path)
|
||||
defer {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path)
|
||||
}
|
||||
|
||||
#expect(!DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: deviceID,
|
||||
role: "operator",
|
||||
token: "must-not-persist"))
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path)
|
||||
#expect(FileManager.default.fileExists(atPath: authURL.path))
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-token")
|
||||
#expect(!FileManager.default.fileExists(atPath: authURL.path))
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `failed invalid-file quarantine aborts the requested write`() throws {
|
||||
let authURL = try Self.authURL()
|
||||
let identityDirectory = authURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: identityDirectory, withIntermediateDirectories: true)
|
||||
try Data([0xFF]).write(to: authURL)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: identityDirectory.path)
|
||||
defer {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path)
|
||||
}
|
||||
|
||||
#expect(!DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: "quarantine-device",
|
||||
role: "node",
|
||||
token: "must-not-persist"))
|
||||
#expect(FileManager.default.fileExists(atPath: authURL.path))
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: identityDirectory.path)
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: "quarantine-device",
|
||||
role: "node",
|
||||
token: "sqlite-token"))
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: "quarantine-device",
|
||||
role: "node")?.token == "sqlite-token")
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `corrupt legacy file is quarantined and SQLite remains writable`() throws {
|
||||
let authURL = try Self.authURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: authURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try Data([0xFF, 0x00, 0xAA]).write(to: authURL)
|
||||
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: "corrupt-device",
|
||||
role: "node",
|
||||
token: "sqlite-token"))
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: "corrupt-device",
|
||||
role: "node")?.token == "sqlite-token")
|
||||
#expect(!FileManager.default.fileExists(atPath: authURL.path))
|
||||
let quarantined = try FileManager.default.contentsOfDirectory(
|
||||
atPath: authURL.deletingLastPathComponent().path)
|
||||
.filter { $0.hasPrefix("device-auth.json.invalid-") }
|
||||
#expect(quarantined.count == 1)
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `clear token distinguishes one scope from every scope`() {
|
||||
let deviceID = "clear-device"
|
||||
_ = DeviceAuthStore.storeToken(deviceId: deviceID, role: "node", token: "unscoped")
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
token: "gateway-a",
|
||||
gatewayID: "gateway-a")
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
token: "gateway-b",
|
||||
gatewayID: "gateway-b")
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceID,
|
||||
role: "operator",
|
||||
token: "operator",
|
||||
gatewayID: "gateway-a")
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "unscoped")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "gateway-a") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "gateway-b")?.token == "gateway-b")
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "node",
|
||||
gatewayID: "gateway-b") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: deviceID,
|
||||
role: "operator",
|
||||
gatewayID: "gateway-a")?.token == "operator")
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `clear all removes only the selected profile identity rows`() {
|
||||
let primary = DeviceIdentityStore.loadOrCreate(profile: .primary)
|
||||
let node = DeviceIdentityStore.loadOrCreate(profile: .node)
|
||||
let share = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
||||
_ = DeviceAuthStore.storeToken(deviceId: primary.deviceId, role: "node", token: "primary")
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: node.deviceId,
|
||||
role: "node",
|
||||
token: "node",
|
||||
profile: .node)
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: share.deviceId,
|
||||
role: "node",
|
||||
token: "share",
|
||||
profile: .shareExtension)
|
||||
|
||||
DeviceAuthStore.clearAll(profile: .shareExtension)
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: primary.deviceId, role: "node")?.token == "primary")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: node.deviceId,
|
||||
role: "node",
|
||||
profile: .node)?.token == "node")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: share.deviceId,
|
||||
role: "node",
|
||||
profile: .shareExtension) == nil)
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `clear all without an identity leaves legacy state untouched`() throws {
|
||||
let legacy = DeviceAuthStoreFile(
|
||||
version: 1,
|
||||
deviceId: "orphaned-device",
|
||||
tokens: [
|
||||
"node": DeviceAuthEntry(
|
||||
token: "legacy-token",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
updatedAtMs: 100),
|
||||
])
|
||||
try Self.writeLegacy(legacy)
|
||||
let authURL = try Self.authURL()
|
||||
let original = try Data(contentsOf: authURL)
|
||||
|
||||
DeviceAuthStore.clearAll()
|
||||
|
||||
#expect(try Data(contentsOf: authURL) == original)
|
||||
#expect(try !FileManager.default.fileExists(atPath: Self.databaseURL().path))
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `version zero bootstrap creates the exact composite key table`() throws {
|
||||
#expect(DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: "bootstrap-device",
|
||||
role: "node",
|
||||
token: "bootstrap-token"))
|
||||
let databaseURL = try Self.databaseURL()
|
||||
|
||||
#expect(try Self.scalarInt(databaseURL, "PRAGMA user_version") == 0)
|
||||
#expect(try Self.scalarText(
|
||||
databaseURL,
|
||||
"""
|
||||
SELECT group_concat(name || ':' || pk, ',')
|
||||
FROM (SELECT name, pk FROM pragma_table_xinfo('device_auth_tokens') WHERE pk > 0 ORDER BY cid)
|
||||
""") == "device_id:1,role:2")
|
||||
#expect(try Self.scalarText(
|
||||
databaseURL,
|
||||
"""
|
||||
SELECT group_concat(name || ':' || "desc", ',')
|
||||
FROM (
|
||||
SELECT name, "desc" FROM pragma_index_xinfo('idx_device_auth_tokens_updated')
|
||||
WHERE key = 1 ORDER BY seqno
|
||||
)
|
||||
""") == "updated_at_ms:1,device_id:0,role:0")
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `versioned database never synthesizes a missing auth table`() throws {
|
||||
let databaseURL = try Self.databaseURL()
|
||||
try Self.execute(databaseURL, """
|
||||
CREATE TABLE schema_meta (
|
||||
meta_key TEXT NOT NULL PRIMARY KEY,
|
||||
role TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL
|
||||
) STRICT;
|
||||
INSERT INTO schema_meta (meta_key, role, schema_version) VALUES ('primary', 'global', 6);
|
||||
PRAGMA user_version = 6;
|
||||
""")
|
||||
|
||||
#expect(!DeviceAuthStore.storeTokenPersisted(
|
||||
deviceId: "versioned-device",
|
||||
role: "node",
|
||||
token: "must-not-persist"))
|
||||
#expect(try Self.scalarInt(
|
||||
databaseURL,
|
||||
"SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'device_auth_tokens'") == 0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DeviceAuthStoreTests {
|
||||
private static func stateDirectoryURL() throws -> URL {
|
||||
let path = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
|
||||
return URL(fileURLWithPath: path, isDirectory: true)
|
||||
}
|
||||
|
||||
private static func databaseURL() throws -> URL {
|
||||
try self.stateDirectoryURL()
|
||||
.appendingPathComponent("state", isDirectory: true)
|
||||
.appendingPathComponent("openclaw.sqlite", isDirectory: false)
|
||||
}
|
||||
|
||||
private static func authURL(
|
||||
profile: GatewayDeviceIdentityProfile = .primary) throws -> URL
|
||||
{
|
||||
try self.stateDirectoryURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(profile.authFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func writeLegacy(
|
||||
_ store: DeviceAuthStoreFile,
|
||||
profile: GatewayDeviceIdentityProfile = .primary) throws
|
||||
{
|
||||
let url = try self.authURL(profile: profile)
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try JSONEncoder().encode(store).write(to: url, options: [.atomic])
|
||||
}
|
||||
|
||||
private static func storageComponent(_ value: String) -> String {
|
||||
Data(value.utf8).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private static func execute(_ databaseURL: URL, _ sql: String) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: databaseURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
var database: OpaquePointer?
|
||||
guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else {
|
||||
throw DeviceIdentityStore.storageError("Could not open test database")
|
||||
}
|
||||
defer { sqlite3_close(database) }
|
||||
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
|
||||
throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database)))
|
||||
}
|
||||
}
|
||||
|
||||
private static func scalarInt(_ databaseURL: URL, _ sql: String) throws -> Int64 {
|
||||
try self.scalar(databaseURL, sql) { sqlite3_column_int64($0, 0) }
|
||||
}
|
||||
|
||||
private static func scalarText(_ databaseURL: URL, _ sql: String) throws -> String? {
|
||||
try self.scalar(databaseURL, sql) { statement in
|
||||
sqlite3_column_text(statement, 0).map { String(cString: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func scalar<T>(
|
||||
_ databaseURL: URL,
|
||||
_ sql: String,
|
||||
transform: (OpaquePointer) -> T) throws -> T
|
||||
{
|
||||
var database: OpaquePointer?
|
||||
guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else {
|
||||
throw DeviceIdentityStore.storageError("Could not open test database")
|
||||
}
|
||||
defer { sqlite3_close(database) }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
|
||||
throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database)))
|
||||
}
|
||||
defer { sqlite3_finalize(statement) }
|
||||
guard sqlite3_step(statement) == SQLITE_ROW else {
|
||||
throw DeviceIdentityStore.storageError(String(cString: sqlite3_errmsg(database)))
|
||||
}
|
||||
return transform(statement)
|
||||
}
|
||||
}
|
||||
@@ -189,12 +189,12 @@ struct DeviceIdentityStoreTests {
|
||||
gatewayID: nextLineOwner)?.token == "next-line-token")
|
||||
|
||||
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
|
||||
let authURL = URL(fileURLWithPath: stateDirPath, isDirectory: true)
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device-auth.json", isDirectory: false)
|
||||
let raw = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: authURL)) as? [String: Any])
|
||||
let tokens = try #require(raw["tokens"] as? [String: Any])
|
||||
#expect(tokens.count == 3)
|
||||
let stateDirURL = URL(fileURLWithPath: stateDirPath, isDirectory: true)
|
||||
#expect(try Self.scalarInt(
|
||||
stateDirURL.appendingPathComponent("state/openclaw.sqlite"),
|
||||
"SELECT COUNT(*) FROM device_auth_tokens WHERE device_id = '\(deviceID)'") == 3)
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: stateDirURL.appendingPathComponent("identity/device-auth.json").path))
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: decomposedOwner)
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
@@ -373,7 +373,7 @@ struct DeviceIdentityStoreTests {
|
||||
}
|
||||
|
||||
@Test(.stateDirectoryIsolated)
|
||||
func `secondary profiles use separate identity rows and auth files`() throws {
|
||||
func `secondary profiles use separate identity and auth rows`() throws {
|
||||
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
||||
let nodeIdentity = DeviceIdentityStore.loadOrCreate(profile: .node)
|
||||
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
||||
@@ -402,10 +402,13 @@ struct DeviceIdentityStoreTests {
|
||||
#expect(try Self.scalarInt(
|
||||
stateDir.appendingPathComponent("state/openclaw.sqlite"),
|
||||
"SELECT COUNT(*) FROM device_identities") == 3)
|
||||
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
|
||||
#expect(FileManager.default
|
||||
#expect(try Self.scalarInt(
|
||||
stateDir.appendingPathComponent("state/openclaw.sqlite"),
|
||||
"SELECT COUNT(*) FROM device_auth_tokens") == 3)
|
||||
#expect(!FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
|
||||
#expect(!FileManager.default
|
||||
.fileExists(atPath: identityDir.appendingPathComponent("node-device-auth.json").path))
|
||||
#expect(FileManager.default
|
||||
#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(
|
||||
@@ -955,7 +958,7 @@ struct DeviceIdentityStoreTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `migration copies auth without clobbering or removing its source`() throws {
|
||||
func `migration imports auth rows without removing its source`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
@@ -964,58 +967,32 @@ struct DeviceIdentityStoreTests {
|
||||
stateDirURL: sourceRoot,
|
||||
profile: .primary,
|
||||
contents: Self.nodePEMIdentityJSON())
|
||||
let auth = "{\"version\":1,\"deviceId\":\"\(Self.fixtureDeviceID)\",\"tokens\":{}}"
|
||||
let auth = """
|
||||
{"version":1,"deviceId":"\(Self
|
||||
.fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":["write"],"updatedAtMs":100}}}
|
||||
"""
|
||||
try auth.write(to: source.authURL, atomically: true, encoding: .utf8)
|
||||
let destination = tempDir.appendingPathComponent("legacy", isDirectory: true)
|
||||
let databaseURL = destination.appendingPathComponent("state/openclaw.sqlite")
|
||||
let destinationAuthURL = destination.appendingPathComponent("identity/device-auth.json")
|
||||
|
||||
_ = try DeviceIdentitySQLiteStore.loadOrCreate(
|
||||
databaseURL: destination.appendingPathComponent("openclaw.sqlite"),
|
||||
databaseURL: databaseURL,
|
||||
destinationStateDirURL: destination,
|
||||
profile: .primary,
|
||||
legacySources: [source])
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
||||
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == auth)
|
||||
#expect(try String(contentsOf: destinationAuthURL, encoding: .utf8) == auth)
|
||||
#expect(!FileManager.default.fileExists(atPath: destinationAuthURL.path))
|
||||
#expect(try Self.scalarText(
|
||||
databaseURL,
|
||||
"SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
|
||||
"source-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `migration rejects destination auth owned by another device`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let sourceRoot = tempDir.appendingPathComponent("shared", isDirectory: true)
|
||||
let source = try Self.writeLegacyIdentity(
|
||||
stateDirURL: sourceRoot,
|
||||
profile: .primary,
|
||||
contents: Self.nodePEMIdentityJSON())
|
||||
let sourceAuth = "{\"version\":1,\"deviceId\":\"\(Self.fixtureDeviceID)\",\"tokens\":{}}"
|
||||
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
|
||||
let destination = tempDir.appendingPathComponent("legacy", isDirectory: true)
|
||||
let destinationAuthURL = destination.appendingPathComponent("identity/device-auth.json")
|
||||
try FileManager.default.createDirectory(
|
||||
at: destinationAuthURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let destinationAuth = #"{"version":1,"deviceId":"another-device","tokens":{}}"#
|
||||
try destinationAuth.write(to: destinationAuthURL, atomically: true, encoding: .utf8)
|
||||
|
||||
#expect(throws: NSError.self) {
|
||||
try DeviceIdentitySQLiteStore.loadOrCreate(
|
||||
databaseURL: destination.appendingPathComponent("openclaw.sqlite"),
|
||||
destinationStateDirURL: destination,
|
||||
profile: .primary,
|
||||
legacySources: [source])
|
||||
}
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
|
||||
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
|
||||
#expect(try String(contentsOf: destinationAuthURL, encoding: .utf8) == destinationAuth)
|
||||
}
|
||||
|
||||
// swiftlint:disable line_length
|
||||
@Test
|
||||
func `migration rejects stale destination auth for the same device`() throws {
|
||||
func `migration preserves canonical destination auth rows`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
@@ -1030,28 +1007,147 @@ struct DeviceIdentityStoreTests {
|
||||
"""
|
||||
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
|
||||
let destination = tempDir.appendingPathComponent("legacy", isDirectory: true)
|
||||
let databaseURL = destination.appendingPathComponent("state/openclaw.sqlite")
|
||||
try FileManager.default.createDirectory(
|
||||
at: databaseURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try Self.execute(databaseURL, """
|
||||
CREATE TABLE device_auth_tokens (
|
||||
device_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
scopes_json TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (device_id, role)
|
||||
) STRICT;
|
||||
CREATE INDEX idx_device_auth_tokens_updated
|
||||
ON device_auth_tokens(updated_at_ms DESC, device_id, role);
|
||||
INSERT INTO device_auth_tokens (device_id, role, token, scopes_json, updated_at_ms)
|
||||
VALUES ('\(Self.fixtureDeviceID)', 'node', 'destination-token', '[]', 200);
|
||||
""")
|
||||
|
||||
_ = try DeviceIdentitySQLiteStore.loadOrCreate(
|
||||
databaseURL: databaseURL,
|
||||
destinationStateDirURL: destination,
|
||||
profile: .primary,
|
||||
legacySources: [source])
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
||||
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
|
||||
#expect(try Self.scalarText(
|
||||
databaseURL,
|
||||
"SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
|
||||
"destination-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `migration imports destination legacy auth before source auth`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let source = try Self.writeLegacyIdentity(
|
||||
stateDirURL: tempDir.appendingPathComponent("source", isDirectory: true),
|
||||
profile: .primary,
|
||||
contents: Self.nodePEMIdentityJSON())
|
||||
let sourceAuth = """
|
||||
{"version":1,"deviceId":"\(Self
|
||||
.fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":[],"updatedAtMs":100}}}
|
||||
"""
|
||||
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
|
||||
let destination = tempDir.appendingPathComponent("destination", isDirectory: true)
|
||||
let destinationAuthURL = destination.appendingPathComponent("identity/device-auth.json")
|
||||
try FileManager.default.createDirectory(
|
||||
at: destinationAuthURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let destinationAuth = "{\"version\":1,\"deviceId\":\"\(Self.fixtureDeviceID)\",\"tokens\":{}}"
|
||||
let destinationAuth = sourceAuth.replacingOccurrences(of: "source-token", with: "destination-token")
|
||||
try destinationAuth.write(to: destinationAuthURL, atomically: true, encoding: .utf8)
|
||||
let databaseURL = destination.appendingPathComponent("state/openclaw.sqlite")
|
||||
|
||||
_ = try DeviceIdentitySQLiteStore.loadOrCreate(
|
||||
databaseURL: databaseURL,
|
||||
destinationStateDirURL: destination,
|
||||
profile: .primary,
|
||||
legacySources: [source])
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
||||
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
|
||||
#expect(!FileManager.default.fileExists(atPath: destinationAuthURL.path))
|
||||
#expect(try Self.scalarText(
|
||||
databaseURL,
|
||||
"SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
|
||||
"destination-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `source auth access failure preserves the identity claim for retry`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let source = try Self.writeLegacyIdentity(
|
||||
stateDirURL: tempDir.appendingPathComponent("source", isDirectory: true),
|
||||
profile: .primary,
|
||||
contents: Self.nodePEMIdentityJSON())
|
||||
let sourceAuth = """
|
||||
{"version":1,"deviceId":"\(Self.fixtureDeviceID)","tokens":{}}
|
||||
"""
|
||||
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: source.authURL.path)
|
||||
defer {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: source.authURL.path)
|
||||
}
|
||||
let destination = tempDir.appendingPathComponent("destination", isDirectory: true)
|
||||
|
||||
#expect(throws: NSError.self) {
|
||||
try DeviceIdentitySQLiteStore.loadOrCreate(
|
||||
databaseURL: destination.appendingPathComponent("openclaw.sqlite"),
|
||||
databaseURL: destination.appendingPathComponent("state/openclaw.sqlite"),
|
||||
destinationStateDirURL: destination,
|
||||
profile: .primary,
|
||||
legacySources: [source])
|
||||
}
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
|
||||
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
|
||||
#expect(try String(contentsOf: destinationAuthURL, encoding: .utf8) == destinationAuth)
|
||||
#expect(FileManager.default.fileExists(atPath: source.authURL.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `migration accepts equivalent auth with reordered scopes`() throws {
|
||||
func `migration rejects conflicting source auth stores`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let identityJSON = try Self.nodePEMIdentityJSON()
|
||||
let first = try Self.writeLegacyIdentity(
|
||||
stateDirURL: tempDir.appendingPathComponent("first", isDirectory: true),
|
||||
profile: .primary,
|
||||
contents: identityJSON)
|
||||
let second = try Self.writeLegacyIdentity(
|
||||
stateDirURL: tempDir.appendingPathComponent("second", isDirectory: true),
|
||||
profile: .primary,
|
||||
contents: identityJSON)
|
||||
let firstAuth = """
|
||||
{"version":1,"deviceId":"\(Self
|
||||
.fixtureDeviceID)","tokens":{"node":{"token":"first-token","role":"node","scopes":[],"updatedAtMs":100}}}
|
||||
"""
|
||||
let secondAuth = firstAuth.replacingOccurrences(of: "first-token", with: "second-token")
|
||||
try firstAuth.write(to: first.authURL, atomically: true, encoding: .utf8)
|
||||
try secondAuth.write(to: second.authURL, atomically: true, encoding: .utf8)
|
||||
let destination = tempDir.appendingPathComponent("destination", isDirectory: true)
|
||||
|
||||
#expect(throws: NSError.self) {
|
||||
try DeviceIdentitySQLiteStore.loadOrCreate(
|
||||
databaseURL: destination.appendingPathComponent("state/openclaw.sqlite"),
|
||||
destinationStateDirURL: destination,
|
||||
profile: .primary,
|
||||
legacySources: [first, second])
|
||||
}
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: first.identityURL.path))
|
||||
#expect(FileManager.default.fileExists(atPath: second.identityURL.path))
|
||||
#expect(try String(contentsOf: first.authURL, encoding: .utf8) == firstAuth)
|
||||
#expect(try String(contentsOf: second.authURL, encoding: .utf8) == secondAuth)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `migration normalizes imported auth scopes`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
@@ -1066,28 +1162,22 @@ struct DeviceIdentityStoreTests {
|
||||
"""
|
||||
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
|
||||
let destination = tempDir.appendingPathComponent("legacy", isDirectory: true)
|
||||
let destinationAuthURL = destination.appendingPathComponent("identity/device-auth.json")
|
||||
try FileManager.default.createDirectory(
|
||||
at: destinationAuthURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let destinationAuth = """
|
||||
{"version":1,"deviceId":"\(Self
|
||||
.fixtureDeviceID)","tokens":{"node":{"token":"source-token","role":"node","scopes":["read","write"],"updatedAtMs":100}}}
|
||||
"""
|
||||
try destinationAuth.write(to: destinationAuthURL, atomically: true, encoding: .utf8)
|
||||
let databaseURL = destination.appendingPathComponent("state/openclaw.sqlite")
|
||||
|
||||
_ = try DeviceIdentitySQLiteStore.loadOrCreate(
|
||||
databaseURL: destination.appendingPathComponent("openclaw.sqlite"),
|
||||
databaseURL: databaseURL,
|
||||
destinationStateDirURL: destination,
|
||||
profile: .primary,
|
||||
legacySources: [source])
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
||||
#expect(try String(contentsOf: destinationAuthURL, encoding: .utf8) == destinationAuth)
|
||||
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
|
||||
#expect(try Self.scalarText(
|
||||
databaseURL,
|
||||
"SELECT scopes_json FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
|
||||
"[\"read\",\"write\"]")
|
||||
}
|
||||
|
||||
// swiftlint:enable line_length
|
||||
|
||||
@Test
|
||||
func `preserves WAL journal mode`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
|
||||
@@ -3098,7 +3098,9 @@ struct GatewayNodeSessionTests {
|
||||
#expect(shareDeviceId != primaryIdentity.deviceId)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?
|
||||
.token == "primary-node-token")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil)
|
||||
// Profile selects identity resolution, not a token namespace; (device_id, role) is the canonical key.
|
||||
// Per-profile identities keep caches disjoint in practice, and Node reads the same table by that key.
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node")?.token == "share-node-token")
|
||||
#expect(
|
||||
DeviceAuthStore
|
||||
.loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token ==
|
||||
|
||||
Reference in New Issue
Block a user