mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
374007083a
* fix(identity): one canonical device-identity contract across app and CLI * fix(ci): satisfy native identity checks * chore: drop changelog edit (release-generation owns CHANGELOG.md)
1262 lines
56 KiB
Swift
1262 lines
56 KiB
Swift
import CryptoKit
|
|
import Darwin
|
|
import Foundation
|
|
import SQLite3
|
|
import Testing
|
|
@testable import OpenClawKit
|
|
|
|
private final class DeviceIdentityMigrationFixture {
|
|
let root: URL
|
|
let destination: URL
|
|
let databaseURL: URL
|
|
|
|
init(destinationName: String = "destination", databasePath: String = "openclaw.sqlite") {
|
|
self.root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
self.destination = self.root.appendingPathComponent(destinationName, isDirectory: true)
|
|
self.databaseURL = self.destination.appendingPathComponent(databasePath, isDirectory: false)
|
|
}
|
|
|
|
deinit {
|
|
try? FileManager.default.removeItem(at: self.root)
|
|
}
|
|
|
|
func url(_ path: String, isDirectory: Bool = true) -> URL {
|
|
self.root.appendingPathComponent(path, isDirectory: isDirectory)
|
|
}
|
|
|
|
func source(
|
|
_ name: String = "legacy",
|
|
profile: GatewayDeviceIdentityProfile = .primary,
|
|
contents: String? = nil) throws -> DeviceIdentityPaths.LegacyIdentitySource
|
|
{
|
|
try DeviceIdentityStoreTests.writeLegacyIdentity(
|
|
stateDirURL: self.url(name),
|
|
profile: profile,
|
|
contents: contents ?? DeviceIdentityStoreTests.nodePEMIdentityJSON())
|
|
}
|
|
|
|
func claimURL(
|
|
for source: DeviceIdentityPaths.LegacyIdentitySource,
|
|
suffix: String = ".native-importing") -> URL
|
|
{
|
|
URL(fileURLWithPath: source.identityURL.path + suffix, isDirectory: false)
|
|
}
|
|
|
|
func load(
|
|
profile: GatewayDeviceIdentityProfile = .primary,
|
|
sources: [DeviceIdentityPaths.LegacyIdentitySource] = [],
|
|
beforeLegacyClaim: ((DeviceIdentityPaths.LegacyIdentitySource) throws -> Void)? = nil,
|
|
afterLegacyCommit: (() throws -> Void)? = nil) throws -> DeviceIdentity
|
|
{
|
|
try DeviceIdentitySQLiteStore.loadOrCreate(
|
|
databaseURL: self.databaseURL,
|
|
destinationStateDirURL: self.destination,
|
|
profile: profile,
|
|
legacySources: sources,
|
|
beforeLegacyClaim: beforeLegacyClaim,
|
|
afterLegacyCommit: afterLegacyCommit)
|
|
}
|
|
}
|
|
|
|
private func deviceAuthEntry(
|
|
_ deviceID: String,
|
|
role: String = "node",
|
|
owner: String? = nil,
|
|
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
|
|
{
|
|
DeviceAuthStore.loadToken(
|
|
deviceId: deviceID,
|
|
role: role,
|
|
gatewayID: owner,
|
|
profile: profile)
|
|
}
|
|
|
|
@Suite(.serialized)
|
|
struct DeviceIdentityStoreTests {
|
|
@Test func `process state root configures once before identity use`() {
|
|
var state = DeviceIdentityStateRootState()
|
|
let work = URL(fileURLWithPath: "/Users/test/.openclaw-work", isDirectory: true)
|
|
let other = URL(fileURLWithPath: "/Users/test/.openclaw-other", isDirectory: true)
|
|
let configuredWork = state.configure(work)
|
|
let reconfiguredWork = state.configure(work)
|
|
let configuredOther = state.configure(other)
|
|
let resolvedWork = state.resolve()
|
|
let configuredWorkAfterUse = state.configure(work)
|
|
let configuredOtherAfterUse = state.configure(other)
|
|
#expect(configuredWork)
|
|
#expect(reconfiguredWork)
|
|
#expect(!configuredOther)
|
|
#expect(resolvedWork == work)
|
|
#expect(configuredWorkAfterUse)
|
|
#expect(!configuredOtherAfterUse)
|
|
|
|
var usedDefault = DeviceIdentityStateRootState()
|
|
let resolvedDefault = usedDefault.resolve()
|
|
let configuredDefaultAfterUse = usedDefault.configure(work)
|
|
#expect(resolvedDefault == nil)
|
|
#expect(!configuredDefaultAfterUse)
|
|
}
|
|
|
|
@Test
|
|
func `task scoped state directories isolate concurrent identity stores`() async throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let root = fixture.root
|
|
let stateDirectories = [
|
|
root.appendingPathComponent("a", isDirectory: true),
|
|
root.appendingPathComponent("b", isDirectory: true),
|
|
]
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
|
|
let observed = await withTaskGroup(of: String.self) { group in
|
|
for stateDirectory in stateDirectories {
|
|
group.addTask {
|
|
await DeviceIdentityStore.withStateDirectory(stateDirectory) {
|
|
let identity = DeviceIdentityStore.loadOrCreate()
|
|
await Task.yield()
|
|
#expect(DeviceIdentityStore.loadOrCreate().deviceId == identity.deviceId)
|
|
return identity.deviceId
|
|
}
|
|
}
|
|
}
|
|
return await group.reduce(into: Set<String>()) { result, deviceId in
|
|
result.insert(deviceId)
|
|
}
|
|
}
|
|
|
|
#expect(observed.count == stateDirectories.count)
|
|
for stateDirectory in stateDirectories {
|
|
#expect(FileManager.default.fileExists(
|
|
atPath: stateDirectory.appendingPathComponent("state/openclaw.sqlite").path))
|
|
}
|
|
}
|
|
|
|
@Test(.stateDirectoryIsolated)
|
|
func `device auth store reports failed durable writes`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let tempDir = fixture.root
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false)
|
|
try Data().write(to: blocker)
|
|
// Repoint the pinned state dir at a plain file to force write failures;
|
|
// the isolation trait restores the env var after the test.
|
|
setenv("OPENCLAW_STATE_DIR", blocker.path, 1)
|
|
|
|
let compatibleEntry: DeviceAuthEntry = DeviceAuthStore.storeToken(
|
|
deviceId: "unwritable-device",
|
|
role: "node",
|
|
token: "must-not-be-acknowledged")
|
|
let stored = DeviceAuthStore.storeTokenResult(
|
|
deviceId: "unwritable-device",
|
|
role: "node",
|
|
token: "must-not-be-acknowledged")
|
|
let publicWritePersisted = DeviceAuthStore.storeTokenPersisted(
|
|
deviceId: "unwritable-device",
|
|
role: "node",
|
|
token: "also-must-not-be-acknowledged")
|
|
let durableIdentity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary)
|
|
|
|
#expect(compatibleEntry.token == "must-not-be-acknowledged")
|
|
#expect(!stored.persisted)
|
|
#expect(!publicWritePersisted)
|
|
#expect(durableIdentity == nil)
|
|
#expect(deviceAuthEntry("unwritable-device") == nil)
|
|
}
|
|
|
|
@Test
|
|
func `device auth entry round-trips epoch milliseconds beyond Int32`() throws {
|
|
let epochMilliseconds: Int64 = 1_800_000_000_000
|
|
let entry = DeviceAuthEntry(
|
|
token: "device-token",
|
|
role: "node",
|
|
scopes: [],
|
|
updatedAtMs: epochMilliseconds)
|
|
|
|
let data = try JSONEncoder().encode(entry)
|
|
let decoded = try JSONDecoder().decode(DeviceAuthEntry.self, from: data)
|
|
|
|
#expect(decoded.updatedAtMs == epochMilliseconds)
|
|
}
|
|
|
|
@Test
|
|
func `durable identity creation verifies persisted key material`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let identity = try fixture.load()
|
|
let reloaded = try fixture.load()
|
|
|
|
#expect(reloaded.deviceId == identity.deviceId)
|
|
#expect(reloaded.publicKey == identity.publicKey)
|
|
#expect(reloaded.privateKey == identity.privateKey)
|
|
}
|
|
|
|
@Test(.stateDirectoryIsolated)
|
|
func `device auth tokens are isolated by gateway owner`() {
|
|
let deviceID = "test-device"
|
|
_ = DeviceAuthStore.storeToken(deviceId: deviceID, role: "node", token: "legacy-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: "gateway-a") == nil)
|
|
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
token: "gateway-a-token",
|
|
gatewayID: "gateway-a")
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
token: "gateway-b-token",
|
|
gatewayID: "gateway-b")
|
|
|
|
#expect(deviceAuthEntry(deviceID)?.token == "legacy-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: "gateway-a")?.token == "gateway-a-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: "gateway-b")?.token == "gateway-b-token")
|
|
|
|
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: "gateway-b")
|
|
#expect(deviceAuthEntry(deviceID, owner: "gateway-a")?.token == "gateway-a-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: "gateway-b") == nil)
|
|
|
|
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node")
|
|
#expect(deviceAuthEntry(deviceID) == nil)
|
|
#expect(deviceAuthEntry(deviceID, owner: "gateway-a") == nil)
|
|
}
|
|
|
|
@Test(.stateDirectoryIsolated)
|
|
func `device auth owners preserve exact unicode bytes`() throws {
|
|
let deviceID = "exact-owner-device"
|
|
let composedOwner = "gateway-\u{00E9}"
|
|
let decomposedOwner = "gateway-e\u{0301}"
|
|
let nextLineOwner = "\u{0085}gateway"
|
|
#expect(composedOwner == decomposedOwner)
|
|
#expect(!DeviceAuthStore.storeTokenPersisted(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
token: "must-not-become-unscoped",
|
|
gatewayID: ""))
|
|
#expect(deviceAuthEntry(deviceID) == nil)
|
|
|
|
for (owner, token) in [
|
|
(composedOwner, "composed-token"),
|
|
(decomposedOwner, "decomposed-token"),
|
|
(nextLineOwner, "next-line-token"),
|
|
] {
|
|
#expect(DeviceAuthStore.storeTokenPersisted(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
token: token,
|
|
gatewayID: owner))
|
|
}
|
|
|
|
#expect(deviceAuthEntry(deviceID, owner: composedOwner)?.token == "composed-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: decomposedOwner)?.token == "decomposed-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: nextLineOwner)?.token == "next-line-token")
|
|
|
|
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
|
|
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(deviceAuthEntry(deviceID, owner: composedOwner)?.token == "composed-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: decomposedOwner) == nil)
|
|
}
|
|
|
|
@Test(.stateDirectoryIsolated)
|
|
func `legacy raw owner keys migrate without canonical aliasing`() throws {
|
|
let deviceID = "legacy-exact-owner-device"
|
|
let composedOwner = "gateway-\u{00E9}"
|
|
let decomposedOwner = "gateway-e\u{0301}"
|
|
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
|
|
let identityURL = URL(fileURLWithPath: stateDirPath, isDirectory: true)
|
|
.appendingPathComponent("identity", isDirectory: true)
|
|
let authURL = identityURL.appendingPathComponent("device-auth.json", isDirectory: false)
|
|
try FileManager.default.createDirectory(at: identityURL, withIntermediateDirectories: true)
|
|
let legacy: [String: Any] = [
|
|
"version": 1,
|
|
"deviceId": deviceID,
|
|
"tokens": [
|
|
"\(composedOwner)\u{1F}node": [
|
|
"token": "legacy-composed-token",
|
|
"role": "node",
|
|
"scopes": [],
|
|
"updatedAtMs": 1,
|
|
"gatewayID": composedOwner,
|
|
],
|
|
],
|
|
]
|
|
try JSONSerialization.data(withJSONObject: legacy).write(to: authURL, options: [.atomic])
|
|
|
|
#expect(deviceAuthEntry(deviceID, owner: composedOwner)?.token == "legacy-composed-token")
|
|
#expect(DeviceAuthStore.storeTokenPersisted(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
token: "new-decomposed-token",
|
|
gatewayID: decomposedOwner))
|
|
#expect(deviceAuthEntry(deviceID, owner: composedOwner)?.token == "legacy-composed-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: decomposedOwner)?.token == "new-decomposed-token")
|
|
}
|
|
|
|
@Test(.stateDirectoryIsolated)
|
|
func `legacy device auth migration claims only the proven role`() {
|
|
let deviceID = "legacy-device"
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
token: "legacy-node-token")
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: deviceID,
|
|
role: "operator",
|
|
token: "legacy-operator-token")
|
|
|
|
#expect(DeviceAuthStore.migrateUnscopedToken(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
toGatewayID: "trusted-gateway"))
|
|
#expect(deviceAuthEntry(deviceID) == nil)
|
|
#expect(deviceAuthEntry(deviceID, owner: "trusted-gateway")?.token == "legacy-node-token")
|
|
#expect(deviceAuthEntry(deviceID, role: "operator", owner: "trusted-gateway") == nil)
|
|
#expect(deviceAuthEntry(deviceID, role: "operator")?.token == "legacy-operator-token")
|
|
#expect(deviceAuthEntry(deviceID, owner: "other-gateway") == nil)
|
|
#expect(!DeviceAuthStore.migrateUnscopedToken(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
toGatewayID: "other-gateway"))
|
|
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: deviceID,
|
|
role: "node",
|
|
token: "ambiguous-legacy-token")
|
|
#expect(DeviceAuthStore.discardUnscopedTokens(deviceId: deviceID) == 2)
|
|
#expect(deviceAuthEntry(deviceID) == nil)
|
|
#expect(deviceAuthEntry(deviceID, owner: "trusted-gateway")?.token == "legacy-node-token")
|
|
}
|
|
|
|
@Test
|
|
func `state directory override wins over shared app group storage`() {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let overrideURL = fixture.url("override")
|
|
let legacyURL = fixture.url("legacy")
|
|
let sharedURL = fixture.url("shared")
|
|
|
|
let selected = DeviceIdentityPaths.stateDirURL(
|
|
overrideURL: overrideURL,
|
|
legacyStateDirURL: legacyURL,
|
|
appGroupStateDirURL: sharedURL,
|
|
temporaryDirectory: fixture.root)
|
|
|
|
#expect(selected == overrideURL)
|
|
#expect(!FileManager.default.fileExists(atPath: sharedURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `shared app group storage wins over legacy app support storage`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let legacyURL = fixture.url("legacy")
|
|
let sharedURL = fixture.url("shared")
|
|
let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true)
|
|
let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false)
|
|
let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true)
|
|
let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false)
|
|
try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true)
|
|
try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8)
|
|
|
|
let selected = DeviceIdentityPaths.stateDirURL(
|
|
overrideURL: nil,
|
|
legacyStateDirURL: legacyURL,
|
|
appGroupStateDirURL: sharedURL,
|
|
temporaryDirectory: fixture.root)
|
|
|
|
#expect(selected == sharedURL)
|
|
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `legacy app support storage wins when app group storage is not available`() {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let legacyURL = fixture.url("legacy")
|
|
let sharedURL = fixture.url("shared")
|
|
|
|
let selected = DeviceIdentityPaths.stateDirURL(
|
|
overrideURL: nil,
|
|
legacyStateDirURL: legacyURL,
|
|
appGroupStateDirURL: sharedURL,
|
|
appGroupStateDirAvailable: false,
|
|
temporaryDirectory: fixture.root)
|
|
|
|
#expect(selected == legacyURL)
|
|
}
|
|
|
|
@Test
|
|
func `task scoped state directory never probes machine legacy roots`() async {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let scopedURL = fixture.url("scoped")
|
|
|
|
await DeviceIdentityStore.withStateDirectory(scopedURL) {
|
|
let sources = DeviceIdentityPaths.legacyIdentitySources(profile: .primary)
|
|
#expect(sources.map(\.stateDirURL) == [scopedURL.standardizedFileURL])
|
|
}
|
|
}
|
|
|
|
@Test(.stateDirectoryIsolated)
|
|
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)
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
token: "primary-token")
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: nodeIdentity.deviceId,
|
|
role: "node",
|
|
token: "node-token",
|
|
profile: .node)
|
|
_ = DeviceAuthStore.storeToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
token: "share-token",
|
|
profile: .shareExtension)
|
|
|
|
// getenv, not ProcessInfo: the trait pins OPENCLAW_STATE_DIR via setenv and
|
|
// ProcessInfo.environment can serve a stale snapshot on Darwin.
|
|
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
|
|
let stateDir = URL(fileURLWithPath: stateDirPath, isDirectory: true)
|
|
let identityDir = stateDir.appendingPathComponent("identity", isDirectory: true)
|
|
#expect(primaryIdentity.deviceId != nodeIdentity.deviceId)
|
|
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
|
|
#expect(try Self.scalarInt(
|
|
stateDir.appendingPathComponent("state/openclaw.sqlite"),
|
|
"SELECT COUNT(*) FROM device_identities") == 3)
|
|
#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
|
|
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
|
|
#expect(deviceAuthEntry(primaryIdentity.deviceId)?.token == "primary-token")
|
|
#expect(deviceAuthEntry(nodeIdentity.deviceId, profile: .node)?.token == "node-token")
|
|
#expect(
|
|
deviceAuthEntry(shareIdentity.deviceId, profile: .shareExtension)?.token ==
|
|
"share-token")
|
|
|
|
DeviceAuthStore.clearAll(profile: .shareExtension)
|
|
|
|
#expect(deviceAuthEntry(primaryIdentity.deviceId)?.token == "primary-token")
|
|
#expect(deviceAuthEntry(nodeIdentity.deviceId, profile: .node)?.token == "node-token")
|
|
#expect(deviceAuthEntry(shareIdentity.deviceId, profile: .shareExtension) == nil)
|
|
}
|
|
|
|
@Test
|
|
func `fresh database creates only canonical identity schema and leaves user version zero`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
_ = try fixture.load()
|
|
|
|
#expect(try Self.scalarInt(fixture.databaseURL, "PRAGMA user_version") == 0)
|
|
#expect(try Self.scalarText(
|
|
fixture.databaseURL,
|
|
"""
|
|
SELECT group_concat(type || ':' || name, ',')
|
|
FROM (SELECT type, name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name)
|
|
""") == "index:idx_device_identities_device,table:device_identities")
|
|
let directoryMode = try #require(
|
|
FileManager.default.attributesOfItem(atPath: fixture.destination.path)[.posixPermissions] as? NSNumber)
|
|
let databaseMode = try #require(
|
|
FileManager.default.attributesOfItem(atPath: fixture.databaseURL.path)[.posixPermissions] as? NSNumber)
|
|
#expect(directoryMode.intValue & 0o777 == 0o700)
|
|
#expect(databaseMode.intValue & 0o777 == 0o600)
|
|
|
|
let coordinatorURLs = DeviceIdentitySQLiteStore.resolveDeviceIdentityCoordinatorURLs(
|
|
databaseURL: fixture.databaseURL,
|
|
destinationStateDirURL: fixture.destination,
|
|
temporaryDirectory: FileManager.default.temporaryDirectory,
|
|
uid: getuid())
|
|
#expect(coordinatorURLs.count == 2)
|
|
for coordinatorURL in coordinatorURLs {
|
|
let coordinatorDirectoryMode = try #require(
|
|
FileManager.default.attributesOfItem(
|
|
atPath: coordinatorURL.deletingLastPathComponent().path)[.posixPermissions] as? NSNumber)
|
|
let coordinatorFileMode = try #require(
|
|
FileManager.default.attributesOfItem(atPath: coordinatorURL.path)[.posixPermissions] as? NSNumber)
|
|
#expect(coordinatorDirectoryMode.intValue & 0o777 == 0o700)
|
|
#expect(coordinatorFileMode.intValue & 0o777 == 0o600)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `Node PEM fixture repairs a stale device id and remains signing compatible`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source(
|
|
"Application Support/OpenClaw",
|
|
contents: Self.nodePEMIdentityJSON(deviceId: "stale-device-id"))
|
|
let identity = try fixture.load(sources: [source])
|
|
|
|
#expect(identity.deviceId == Self.fixtureDeviceID)
|
|
#expect(identity.publicKey == Self.fixturePublicKeyRaw)
|
|
#expect(identity.privateKey == Self.fixturePrivateKeyRaw)
|
|
#expect(identity.createdAtMs == 1_800_000_000_000)
|
|
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(try Self.scalarText(
|
|
fixture.databaseURL,
|
|
"SELECT public_key_pem FROM device_identities WHERE identity_key = 'primary'") == Self.fixturePublicKeyPEM)
|
|
#expect(try Self.scalarText(
|
|
fixture.databaseURL,
|
|
"SELECT private_key_pem FROM device_identities WHERE identity_key = 'primary'") == Self
|
|
.fixturePrivateKeyPEM)
|
|
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
|
|
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
|
|
let publicKey = try Curve25519.Signing.PublicKey(
|
|
rawRepresentation: #require(Data(base64Encoded: identity.publicKey)))
|
|
#expect(try publicKey.isValidSignature(
|
|
#require(Self.base64UrlDecode(signature)),
|
|
for: Data("hello".utf8)))
|
|
}
|
|
|
|
@Test
|
|
func `reads a canonical Node SQLite row without rewriting it`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
try Self.seedCanonicalSchema(fixture.databaseURL, nodeOwned: true)
|
|
try Self.execute(fixture.databaseURL, """
|
|
INSERT INTO device_identities (
|
|
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
|
|
) VALUES (
|
|
'node', '\(Self.fixtureDeviceID)', '\(Self.sql(Self.fixturePublicKeyPEM))',
|
|
'\(Self.sql(Self.fixturePrivateKeyPEM))', 1800000000000, 1800000000123
|
|
)
|
|
""")
|
|
|
|
let identity = try fixture.load(profile: .node)
|
|
|
|
#expect(identity.deviceId == Self.fixtureDeviceID)
|
|
#expect(identity.createdAtMs == 1_800_000_000_000)
|
|
#expect(try Self.scalarInt(
|
|
fixture.databaseURL,
|
|
"SELECT updated_at_ms FROM device_identities WHERE identity_key = 'node'") == 1_800_000_000_123)
|
|
}
|
|
|
|
@Test
|
|
func `adopts a Node row and creates primary while the global version zero migration is pending`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
try Self.seedCanonicalSchema(fixture.databaseURL)
|
|
try Self.execute(fixture.databaseURL, """
|
|
CREATE TABLE plugin_state_entries (entry_key TEXT NOT NULL PRIMARY KEY) STRICT;
|
|
INSERT INTO device_identities (
|
|
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
|
|
) VALUES (
|
|
'node', '\(Self.fixtureDeviceID)', '\(Self.sql(Self.fixturePublicKeyPEM))',
|
|
'\(Self.sql(Self.fixturePrivateKeyPEM))', 1800000000000, 1800000000123
|
|
)
|
|
""")
|
|
|
|
let nodeIdentity = try fixture.load(profile: .node)
|
|
let primaryIdentity = try fixture.load()
|
|
|
|
#expect(nodeIdentity.deviceId == Self.fixtureDeviceID)
|
|
#expect(nodeIdentity.createdAtMs == 1_800_000_000_000)
|
|
#expect(primaryIdentity.deviceId != nodeIdentity.deviceId)
|
|
#expect(try Self.scalarInt(fixture.databaseURL, "PRAGMA user_version") == 0)
|
|
#expect(try Self.scalarInt(
|
|
fixture.databaseURL,
|
|
"SELECT updated_at_ms FROM device_identities WHERE identity_key = 'node'") == 1_800_000_000_123)
|
|
#expect(try Self.scalarInt(fixture.databaseURL, "SELECT COUNT(*) FROM device_identities") == 2)
|
|
}
|
|
|
|
@Test
|
|
func `gateway identity failure names corrupt row and state directory without rotating it`() async throws {
|
|
let fixture = DeviceIdentityMigrationFixture(databasePath: "state/openclaw.sqlite")
|
|
try Self.seedCanonicalSchema(fixture.databaseURL)
|
|
try Self.execute(fixture.databaseURL, """
|
|
INSERT INTO device_identities (
|
|
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
|
|
) VALUES (
|
|
'primary', '\(Self.fixtureDeviceID)', '\(Self.sql(Self.fixturePublicKeyPEM))',
|
|
'not-a-private-key', 1800000000000, 1800000000123
|
|
)
|
|
""")
|
|
|
|
await DeviceIdentityStore.withStateDirectory(fixture.destination) {
|
|
do {
|
|
_ = try GatewayChannelActor.loadDeviceIdentityForConnect(
|
|
includeDeviceIdentity: true,
|
|
profile: .primary)
|
|
Issue.record("corrupt identity unexpectedly loaded")
|
|
} catch {
|
|
let message = error.localizedDescription
|
|
#expect(message.contains("Could not access the persisted device identity"))
|
|
#expect(message.contains("row \"primary\""))
|
|
#expect(message.contains(fixture.destination.path))
|
|
#expect(message.contains("invalid key material"))
|
|
}
|
|
}
|
|
#expect(try Self.scalarText(
|
|
fixture.databaseURL,
|
|
"SELECT private_key_pem FROM device_identities WHERE identity_key = 'primary'") ==
|
|
"not-a-private-key")
|
|
}
|
|
|
|
@Test
|
|
func `same key migration preserves the authoritative SQLite timestamp`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source()
|
|
let identity = try fixture.load(
|
|
sources: [source],
|
|
beforeLegacyClaim: { _ in
|
|
try Self.seedCanonicalSchema(fixture.databaseURL, nodeOwned: true)
|
|
try Self.execute(fixture.databaseURL, """
|
|
INSERT INTO device_identities (
|
|
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
|
|
) VALUES (
|
|
'primary', '\(Self.fixtureDeviceID)', '\(Self.sql(Self.fixturePublicKeyPEM))',
|
|
'\(Self.sql(Self.fixturePrivateKeyPEM))', 1700000000000, 1700000000123
|
|
)
|
|
""")
|
|
})
|
|
|
|
#expect(identity.deviceId == Self.fixtureDeviceID)
|
|
#expect(identity.createdAtMs == 1_700_000_000_000)
|
|
#expect(try Self.scalarInt(
|
|
fixture.databaseURL,
|
|
"SELECT updated_at_ms FROM device_identities WHERE identity_key = 'primary'") == 1_700_000_000_123)
|
|
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
}
|
|
|
|
@Test(arguments: [GatewayDeviceIdentityProfile.primary, .node])
|
|
func `canonical SQLite identity ignores a recreated retired file`(
|
|
profile: GatewayDeviceIdentityProfile) throws
|
|
{
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let canonical = try fixture.load(profile: profile)
|
|
let createdAt = try Self.scalarInt(
|
|
fixture.databaseURL,
|
|
"SELECT created_at_ms FROM device_identities WHERE identity_key = '\(profile.rawValue)'")
|
|
let updatedAt = try Self.scalarInt(
|
|
fixture.databaseURL,
|
|
"SELECT updated_at_ms FROM device_identities WHERE identity_key = '\(profile.rawValue)'")
|
|
let source = try fixture.source(profile: profile)
|
|
let legacyBytes = try Data(contentsOf: source.identityURL)
|
|
|
|
let loaded = try fixture.load(
|
|
profile: profile,
|
|
sources: [source],
|
|
beforeLegacyClaim: { _ in Issue.record("canonical identity inspected a retired source") })
|
|
|
|
#expect(loaded == canonical)
|
|
#expect(canonical.deviceId != Self.fixtureDeviceID)
|
|
#expect(try Self.scalarInt(
|
|
fixture.databaseURL,
|
|
"SELECT created_at_ms FROM device_identities WHERE identity_key = '\(profile.rawValue)'") == createdAt)
|
|
#expect(try Self.scalarInt(
|
|
fixture.databaseURL,
|
|
"SELECT updated_at_ms FROM device_identities WHERE identity_key = '\(profile.rawValue)'") == updatedAt)
|
|
#expect(try Data(contentsOf: source.identityURL) == legacyBytes)
|
|
#expect(!FileManager.default.fileExists(atPath: fixture.claimURL(for: source).path))
|
|
}
|
|
|
|
@Test
|
|
func `strict legacy validation preserves invalid source and creates no row`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let invalidJSON = try Self.nodePEMIdentityJSON(privateKeyPem: "not-a-private-key")
|
|
let source = try fixture.source("shared", contents: invalidJSON)
|
|
|
|
#expect(throws: NSError.self) {
|
|
try fixture.load(sources: [source])
|
|
}
|
|
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `pending Doctor claim blocks identity generation`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source()
|
|
let claimURL = fixture.claimURL(for: source, suffix: ".doctor-importing")
|
|
try FileManager.default.moveItem(at: source.identityURL, to: claimURL)
|
|
|
|
#expect(throws: NSError.self) {
|
|
try fixture.load(sources: [source])
|
|
}
|
|
|
|
#expect(FileManager.default.fileExists(atPath: claimURL.path))
|
|
#expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `Doctor rename winning native claim race blocks identity generation`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source()
|
|
let claimURL = fixture.claimURL(for: source, suffix: ".doctor-importing")
|
|
|
|
#expect(throws: NSError.self) {
|
|
try fixture.load(
|
|
sources: [source],
|
|
beforeLegacyClaim: { _ in
|
|
try FileManager.default.moveItem(at: source.identityURL, to: claimURL)
|
|
})
|
|
}
|
|
|
|
#expect(FileManager.default.fileExists(atPath: claimURL.path))
|
|
#expect(!FileManager.default.fileExists(atPath: fixture.databaseURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `interrupted native claim resumes without rotating identity`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source()
|
|
let claimURL = fixture.claimURL(for: source)
|
|
try FileManager.default.moveItem(at: source.identityURL, to: claimURL)
|
|
let identity = try fixture.load(sources: [source])
|
|
|
|
#expect(identity.deviceId == Self.fixtureDeviceID)
|
|
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(!FileManager.default.fileExists(atPath: claimURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `matching recreated source parks stale native claim`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source(
|
|
contents: Self.nodePEMIdentityJSON(deviceId: "recreated-source"))
|
|
let claimURL = fixture.claimURL(for: source)
|
|
try Self.nodePEMIdentityJSON(deviceId: "interrupted-claim")
|
|
.write(to: claimURL, atomically: true, encoding: .utf8)
|
|
let identity = try fixture.load(sources: [source])
|
|
|
|
#expect(identity.deviceId == Self.fixtureDeviceID)
|
|
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(!FileManager.default.fileExists(atPath: claimURL.path))
|
|
let parkedClaims = try FileManager.default.contentsOfDirectory(
|
|
at: source.identityURL.deletingLastPathComponent(),
|
|
includingPropertiesForKeys: nil)
|
|
.filter { $0.lastPathComponent.hasPrefix("device.json.native-importing.stale-") }
|
|
#expect(parkedClaims.count == 1)
|
|
}
|
|
|
|
@Test
|
|
func `conflicting recreated source preserves native claim`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source()
|
|
let claimURL = fixture.claimURL(for: source)
|
|
try JSONEncoder().encode(DeviceIdentityStore.generateMaterial().identity).write(to: claimURL)
|
|
|
|
do {
|
|
_ = try fixture.load(sources: [source])
|
|
Issue.record("Expected conflicting source and claim to throw")
|
|
} catch let error as NSError {
|
|
#expect(error.localizedDescription ==
|
|
"Legacy device identity source and interrupted native claim both exist")
|
|
}
|
|
|
|
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: claimURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `unparseable native claim is preserved with recreated source`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source()
|
|
let claimURL = fixture.claimURL(for: source)
|
|
try Data("not-json".utf8).write(to: claimURL)
|
|
|
|
do {
|
|
_ = try fixture.load(sources: [source])
|
|
Issue.record("Expected unparseable claim to throw")
|
|
} catch let error as NSError {
|
|
#expect(error.localizedDescription ==
|
|
"Legacy device identity source and interrupted native claim both exist")
|
|
}
|
|
|
|
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: claimURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `source reappearance preserves both native claim and recreated source`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let legacyData = try Self.nodePEMIdentityJSON()
|
|
let source = try fixture.source(contents: legacyData)
|
|
let claimURL = fixture.claimURL(for: source)
|
|
|
|
#expect(throws: NSError.self) {
|
|
try fixture.load(
|
|
sources: [source],
|
|
afterLegacyCommit: {
|
|
try legacyData.write(to: source.identityURL, atomically: true, encoding: .utf8)
|
|
})
|
|
}
|
|
|
|
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: claimURL.path))
|
|
#expect(try Self.scalarText(
|
|
fixture.databaseURL,
|
|
"SELECT device_id FROM device_identities WHERE identity_key = 'primary'") == Self.fixtureDeviceID)
|
|
}
|
|
|
|
@Test
|
|
func `multi source rollback restores every independent native claim`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let legacyData = try Self.nodePEMIdentityJSON()
|
|
let first = try fixture.source("first", contents: legacyData)
|
|
let second = try fixture.source("second", contents: legacyData)
|
|
let firstClaimURL = fixture.claimURL(for: first)
|
|
let secondClaimURL = fixture.claimURL(for: second)
|
|
|
|
#expect(throws: NSError.self) {
|
|
try fixture.load(
|
|
sources: [first, second],
|
|
afterLegacyCommit: {
|
|
try legacyData.write(to: second.identityURL, atomically: true, encoding: .utf8)
|
|
})
|
|
}
|
|
|
|
#expect(FileManager.default.fileExists(atPath: first.identityURL.path))
|
|
#expect(!FileManager.default.fileExists(atPath: firstClaimURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: second.identityURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: secondClaimURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `post commit identity change restores native claim before cleanup`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let source = try fixture.source()
|
|
let claimURL = fixture.claimURL(for: source)
|
|
|
|
#expect(throws: NSError.self) {
|
|
try fixture.load(
|
|
sources: [source],
|
|
afterLegacyCommit: {
|
|
try Self.execute(
|
|
fixture.databaseURL,
|
|
"UPDATE device_identities SET device_id = 'tampered' WHERE identity_key = 'primary'")
|
|
})
|
|
}
|
|
|
|
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(!FileManager.default.fileExists(atPath: claimURL.path))
|
|
#expect(try Self.scalarText(
|
|
fixture.databaseURL,
|
|
"SELECT device_id FROM device_identities WHERE identity_key = 'primary'") == "tampered")
|
|
}
|
|
|
|
@Test
|
|
func `legacy claim rejects symbolic traversal and hard linked sources`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let root = fixture.root
|
|
let realRoot = fixture.url("real")
|
|
let real = try Self.writeLegacyIdentity(
|
|
stateDirURL: realRoot,
|
|
profile: .primary,
|
|
contents: Self.nodePEMIdentityJSON())
|
|
let symbolicRoot = fixture.url("symbolic")
|
|
let symbolic = Self.legacyIdentitySource(stateDirURL: symbolicRoot, profile: .primary)
|
|
try FileManager.default.createDirectory(
|
|
at: symbolic.identityURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try FileManager.default.createSymbolicLink(at: symbolic.identityURL, withDestinationURL: real.identityURL)
|
|
#expect(throws: NSError.self) {
|
|
try DeviceIdentitySQLiteStore.loadOrCreate(
|
|
databaseURL: root.appendingPathComponent("symbolic.sqlite"),
|
|
destinationStateDirURL: root,
|
|
profile: .primary,
|
|
legacySources: [symbolic])
|
|
}
|
|
|
|
let traversingRoot = fixture.url("traversing")
|
|
let traversing = Self.legacyIdentitySource(stateDirURL: traversingRoot, profile: .primary)
|
|
try FileManager.default.createDirectory(at: traversingRoot, withIntermediateDirectories: true)
|
|
try FileManager.default.createSymbolicLink(
|
|
at: traversing.identityURL.deletingLastPathComponent(),
|
|
withDestinationURL: real.identityURL.deletingLastPathComponent())
|
|
#expect(throws: NSError.self) {
|
|
try DeviceIdentitySQLiteStore.loadOrCreate(
|
|
databaseURL: root.appendingPathComponent("traversing.sqlite"),
|
|
destinationStateDirURL: root,
|
|
profile: .primary,
|
|
legacySources: [traversing])
|
|
}
|
|
|
|
let hardRoot = fixture.url("hard")
|
|
let hard = Self.legacyIdentitySource(stateDirURL: hardRoot, profile: .primary)
|
|
try FileManager.default.createDirectory(
|
|
at: hard.identityURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try FileManager.default.linkItem(at: real.identityURL, to: hard.identityURL)
|
|
#expect(throws: NSError.self) {
|
|
try DeviceIdentitySQLiteStore.loadOrCreate(
|
|
databaseURL: root.appendingPathComponent("hard.sqlite"),
|
|
destinationStateDirURL: root,
|
|
profile: .primary,
|
|
legacySources: [hard])
|
|
}
|
|
#expect(FileManager.default.fileExists(atPath: real.identityURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: hard.identityURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `migration imports auth rows without removing its source`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture(
|
|
destinationName: "legacy",
|
|
databasePath: "state/openclaw.sqlite")
|
|
let source = try fixture.source("shared")
|
|
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 destinationAuthURL = fixture.destination.appendingPathComponent("identity/device-auth.json")
|
|
|
|
_ = try fixture.load(sources: [source])
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == auth)
|
|
#expect(!FileManager.default.fileExists(atPath: destinationAuthURL.path))
|
|
#expect(try Self.scalarText(
|
|
fixture.databaseURL,
|
|
"SELECT token FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
|
|
"source-token")
|
|
}
|
|
|
|
@Test
|
|
func `migration preserves canonical destination auth rows`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture(
|
|
destinationName: "legacy",
|
|
databasePath: "state/openclaw.sqlite")
|
|
let source = try fixture.source("shared")
|
|
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)
|
|
try FileManager.default.createDirectory(
|
|
at: fixture.databaseURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try Self.execute(fixture.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 fixture.load(sources: [source])
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
|
|
#expect(try Self.scalarText(
|
|
fixture.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 fixture = DeviceIdentityMigrationFixture(databasePath: "state/openclaw.sqlite")
|
|
let source = try fixture.source("source")
|
|
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 destinationAuthURL = fixture.destination.appendingPathComponent("identity/device-auth.json")
|
|
try FileManager.default.createDirectory(
|
|
at: destinationAuthURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
let destinationAuth = sourceAuth.replacingOccurrences(of: "source-token", with: "destination-token")
|
|
try destinationAuth.write(to: destinationAuthURL, atomically: true, encoding: .utf8)
|
|
|
|
_ = try fixture.load(sources: [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(
|
|
fixture.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 fixture = DeviceIdentityMigrationFixture(databasePath: "state/openclaw.sqlite")
|
|
let source = try fixture.source("source")
|
|
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)
|
|
}
|
|
|
|
#expect(throws: NSError.self) {
|
|
try fixture.load(sources: [source])
|
|
}
|
|
|
|
#expect(FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: source.authURL.path))
|
|
}
|
|
|
|
@Test
|
|
func `migration rejects conflicting source auth stores`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture(databasePath: "state/openclaw.sqlite")
|
|
let identityJSON = try Self.nodePEMIdentityJSON()
|
|
let first = try fixture.source("first", contents: identityJSON)
|
|
let second = try fixture.source("second", 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)
|
|
|
|
#expect(throws: NSError.self) {
|
|
try fixture.load(sources: [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 fixture = DeviceIdentityMigrationFixture(
|
|
destinationName: "legacy",
|
|
databasePath: "state/openclaw.sqlite")
|
|
let source = try fixture.source("shared")
|
|
let sourceAuth = """
|
|
{"version":1,"deviceId":"\(Self
|
|
.fixtureDeviceID)","tokens":{"legacy":{"token":"source-token","role":"node","scopes":["write","read"],"updatedAtMs":100}}}
|
|
"""
|
|
try sourceAuth.write(to: source.authURL, atomically: true, encoding: .utf8)
|
|
|
|
_ = try fixture.load(sources: [source])
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: source.identityURL.path))
|
|
#expect(try String(contentsOf: source.authURL, encoding: .utf8) == sourceAuth)
|
|
#expect(try Self.scalarText(
|
|
fixture.databaseURL,
|
|
"SELECT scopes_json FROM device_auth_tokens WHERE device_id = '\(Self.fixtureDeviceID)' AND role = 'node'") ==
|
|
"[\"read\",\"write\"]")
|
|
}
|
|
|
|
@Test
|
|
func `preserves WAL journal mode`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
try Self.seedCanonicalSchema(fixture.databaseURL)
|
|
#expect(try Self.scalarText(fixture.databaseURL, "PRAGMA journal_mode = WAL")?.lowercased() == "wal")
|
|
|
|
_ = try fixture.load()
|
|
|
|
#expect(try Self.scalarText(fixture.databaseURL, "PRAGMA journal_mode")?.lowercased() == "wal")
|
|
}
|
|
|
|
@Test
|
|
func `fails closed for nonempty missing wrong and newer schemas`() throws {
|
|
let fixture = DeviceIdentityMigrationFixture()
|
|
let root = fixture.root
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
|
|
let missing = root.appendingPathComponent("missing.sqlite")
|
|
try Self.execute(missing, "CREATE TABLE unrelated (id INTEGER PRIMARY KEY) STRICT")
|
|
#expect(throws: NSError.self) {
|
|
try DeviceIdentitySQLiteStore.loadOrCreate(
|
|
databaseURL: missing,
|
|
destinationStateDirURL: root,
|
|
profile: .primary)
|
|
}
|
|
|
|
let wrong = root.appendingPathComponent("wrong.sqlite")
|
|
try Self.execute(wrong, """
|
|
CREATE TABLE device_identities (
|
|
identity_key TEXT NOT NULL PRIMARY KEY,
|
|
device_id TEXT NOT NULL
|
|
) STRICT;
|
|
CREATE INDEX idx_device_identities_device ON device_identities(device_id);
|
|
""")
|
|
#expect(throws: NSError.self) {
|
|
try DeviceIdentitySQLiteStore.loadOrCreate(
|
|
databaseURL: wrong,
|
|
destinationStateDirURL: root,
|
|
profile: .primary)
|
|
}
|
|
|
|
let newer = root.appendingPathComponent("newer.sqlite")
|
|
try Self.execute(newer, "PRAGMA user_version = 4")
|
|
#expect(throws: NSError.self) {
|
|
try DeviceIdentitySQLiteStore.loadOrCreate(
|
|
databaseURL: newer,
|
|
destinationStateDirURL: root,
|
|
profile: .primary)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension DeviceIdentityStoreTests {
|
|
fileprivate static func base64UrlDecode(_ value: String) -> Data? {
|
|
let normalized = value
|
|
.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
let padded = normalized + String(repeating: "=", count: (4 - normalized.count % 4) % 4)
|
|
return Data(base64Encoded: padded)
|
|
}
|
|
|
|
fileprivate static let fixtureDeviceID = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c"
|
|
fileprivate static let fixturePublicKeyRaw = "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="
|
|
fileprivate static let fixturePublicKeyData = Data(base64Encoded: Self.fixturePublicKeyRaw)!
|
|
fileprivate static let fixturePrivateKeyData = Data((0..<32).map { UInt8($0) })
|
|
fileprivate static let fixturePrivateKeyRaw = Self.fixturePrivateKeyData.base64EncodedString()
|
|
fileprivate static let fixturePublicKeyPEM = Self.fixturePEM(
|
|
label: "PUBLIC KEY",
|
|
der: Data([
|
|
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
|
|
0x70, 0x03, 0x21, 0x00,
|
|
]) + Self.fixturePublicKeyData)
|
|
fileprivate static let fixturePrivateKeyPEM = Self.fixturePEM(
|
|
label: "PRIVATE KEY",
|
|
der: Data([
|
|
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
|
|
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
|
]) + Self.fixturePrivateKeyData)
|
|
|
|
fileprivate static func fixturePEM(label: String, der: Data) -> String {
|
|
let fence = String(repeating: "-", count: 5)
|
|
return "\(fence)BEGIN \(label)\(fence)\n\(der.base64EncodedString())\n\(fence)END \(label)\(fence)\n"
|
|
}
|
|
|
|
fileprivate static func nodePEMIdentityJSON(
|
|
deviceId: String = Self.fixtureDeviceID,
|
|
privateKeyPem: String = Self.fixturePrivateKeyPEM) throws -> String
|
|
{
|
|
let object: [String: Any] = [
|
|
"version": 1,
|
|
"deviceId": deviceId,
|
|
"publicKeyPem": self.fixturePublicKeyPEM,
|
|
"privateKeyPem": privateKeyPem,
|
|
"createdAtMs": Int64(1_800_000_000_000),
|
|
]
|
|
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
|
throw DeviceIdentityStore.storageError("Could not encode fixture identity JSON")
|
|
}
|
|
return json + "\n"
|
|
}
|
|
|
|
fileprivate static func writeLegacyIdentity(
|
|
stateDirURL: URL,
|
|
profile: GatewayDeviceIdentityProfile,
|
|
contents: String) throws -> DeviceIdentityPaths.LegacyIdentitySource
|
|
{
|
|
let source = self.legacyIdentitySource(stateDirURL: stateDirURL, profile: profile)
|
|
try FileManager.default.createDirectory(
|
|
at: source.identityURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try contents.write(to: source.identityURL, atomically: true, encoding: .utf8)
|
|
return source
|
|
}
|
|
|
|
fileprivate static func legacyIdentitySource(
|
|
stateDirURL: URL,
|
|
profile: GatewayDeviceIdentityProfile) -> DeviceIdentityPaths.LegacyIdentitySource
|
|
{
|
|
let stateDirURL = stateDirURL.standardizedFileURL
|
|
let identityDirURL = stateDirURL.appendingPathComponent("identity", isDirectory: true)
|
|
return DeviceIdentityPaths.LegacyIdentitySource(
|
|
stateDirURL: stateDirURL,
|
|
identityURL: identityDirURL.appendingPathComponent(profile.identityFileName, isDirectory: false),
|
|
authURL: identityDirURL.appendingPathComponent(profile.authFileName, isDirectory: false))
|
|
}
|
|
|
|
fileprivate static func seedCanonicalSchema(_ databaseURL: URL, nodeOwned: Bool = false) throws {
|
|
try FileManager.default.createDirectory(
|
|
at: databaseURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
let metadataSQL = nodeOwned
|
|
? """
|
|
CREATE TABLE schema_meta (
|
|
meta_key TEXT NOT NULL PRIMARY KEY,
|
|
role TEXT NOT NULL,
|
|
schema_version INTEGER NOT NULL,
|
|
agent_id TEXT,
|
|
app_version TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
) STRICT;
|
|
INSERT INTO schema_meta (
|
|
meta_key, role, schema_version, agent_id, app_version, created_at, updated_at
|
|
) VALUES ('primary', 'global', 4, NULL, NULL, 1800000000000, 1800000000000);
|
|
PRAGMA user_version = 4;
|
|
"""
|
|
: ""
|
|
try self.execute(databaseURL, """
|
|
CREATE TABLE device_identities (
|
|
identity_key TEXT NOT NULL PRIMARY KEY,
|
|
device_id TEXT NOT NULL,
|
|
public_key_pem TEXT NOT NULL,
|
|
private_key_pem TEXT NOT NULL,
|
|
created_at_ms INTEGER NOT NULL,
|
|
updated_at_ms INTEGER NOT NULL
|
|
) STRICT;
|
|
CREATE INDEX idx_device_identities_device
|
|
ON device_identities(device_id, updated_at_ms DESC);
|
|
\(metadataSQL)
|
|
""")
|
|
}
|
|
|
|
fileprivate static func execute(_ databaseURL: URL, _ sql: String) throws {
|
|
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)))
|
|
}
|
|
}
|
|
|
|
fileprivate static func scalarInt(_ databaseURL: URL, _ sql: String) throws -> Int64 {
|
|
try self.scalar(databaseURL, sql) { sqlite3_column_int64($0, 0) }
|
|
}
|
|
|
|
fileprivate 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) }
|
|
}
|
|
}
|
|
|
|
fileprivate 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)
|
|
}
|
|
|
|
fileprivate static func sql(_ value: String) -> String {
|
|
value.replacingOccurrences(of: "'", with: "''")
|
|
}
|
|
}
|