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:
Peter Steinberger
2026-07-29 02:33:03 -04:00
committed by GitHub
parent 32e522e88b
commit b2701ac9cf
6 changed files with 1076 additions and 244 deletions

View File

@@ -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))
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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 ==