@@ -196,6 +196,24 @@ struct DeviceIdentityStoreTests {
196196 #expect( !FileManager. default. fileExists ( atPath: sharedDeviceURL. path) )
197197 }
198198
199+ @Test
200+ func `legacy app support storage wins when app group storage is not available`() {
201+ let tempDir = FileManager . default. temporaryDirectory
202+ . appendingPathComponent ( UUID ( ) . uuidString, isDirectory: true )
203+ defer { try ? FileManager . default. removeItem ( at: tempDir) }
204+ let legacyURL = tempDir. appendingPathComponent ( " legacy " , isDirectory: true )
205+ let sharedURL = tempDir. appendingPathComponent ( " shared " , isDirectory: true )
206+
207+ let selected = DeviceIdentityPaths . stateDirURL (
208+ overrideURL: nil ,
209+ legacyStateDirURL: legacyURL,
210+ appGroupStateDirURL: sharedURL,
211+ appGroupStateDirAvailable: false ,
212+ temporaryDirectory: tempDir)
213+
214+ #expect( selected == legacyURL)
215+ }
216+
199217 @Test ( . stateDirectoryIsolated)
200218 func `secondary profiles use separate identity and auth files`() throws {
201219 let tempDir = FileManager . default. temporaryDirectory
@@ -326,6 +344,174 @@ struct DeviceIdentityStoreTests {
326344 #expect( try String ( contentsOf: identityURL, encoding: . utf8) == before)
327345 }
328346
347+ @Test
348+ func `does not overwrite an existing unrecognized identity file`() throws {
349+ let tempDir = FileManager . default. temporaryDirectory
350+ . appendingPathComponent ( UUID ( ) . uuidString, isDirectory: true )
351+ let identityURL = tempDir
352+ . appendingPathComponent ( " identity " , isDirectory: true )
353+ . appendingPathComponent ( " device.json " , isDirectory: false )
354+ defer { try ? FileManager . default. removeItem ( at: tempDir) }
355+ try FileManager . default. createDirectory (
356+ at: identityURL. deletingLastPathComponent ( ) ,
357+ withIntermediateDirectories: true )
358+ let stored = """
359+ {
360+ " schema " : " future-openclaw-device-identity " ,
361+ " stableDeviceId " : " app-group-device-id "
362+ }
363+ """
364+ try stored. write ( to: identityURL, atomically: true , encoding: . utf8)
365+ let before = try String ( contentsOf: identityURL, encoding: . utf8)
366+
367+ let identity = DeviceIdentityStore . loadOrCreate ( fileURL: identityURL)
368+
369+ #expect( identity. deviceId != " app-group-device-id " )
370+ #expect( try String ( contentsOf: identityURL, encoding: . utf8) == before)
371+ }
372+
373+ @Test
374+ func `migrates an existing app group identity and auth store when falling back to legacy storage`() throws {
375+ let tempDir = FileManager . default. temporaryDirectory
376+ . appendingPathComponent ( UUID ( ) . uuidString, isDirectory: true )
377+ defer { try ? FileManager . default. removeItem ( at: tempDir) }
378+ let appGroupURL = tempDir. appendingPathComponent ( " shared " , isDirectory: true )
379+ let appGroupIdentityURL = appGroupURL
380+ . appendingPathComponent ( " identity " , isDirectory: true )
381+ . appendingPathComponent ( " device.json " , isDirectory: false )
382+ let appGroupAuthURL = appGroupIdentityURL
383+ . deletingLastPathComponent ( )
384+ . appendingPathComponent ( " device-auth.json " , isDirectory: false )
385+ let legacyIdentityURL = tempDir
386+ . appendingPathComponent ( " legacy " , isDirectory: true )
387+ . appendingPathComponent ( " identity " , isDirectory: true )
388+ . appendingPathComponent ( " device.json " , isDirectory: false )
389+ let legacyAuthURL = legacyIdentityURL
390+ . deletingLastPathComponent ( )
391+ . appendingPathComponent ( " device-auth.json " , isDirectory: false )
392+ try FileManager . default. createDirectory (
393+ at: appGroupIdentityURL. deletingLastPathComponent ( ) ,
394+ withIntermediateDirectories: true )
395+ let stored = try Self . identityJSON (
396+ publicKeyPem: Self . pem (
397+ label: " PUBLIC KEY " ,
398+ body: " MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg= " ) ,
399+ privateKeyPem: Self . pem (
400+ label: " PRIVATE KEY " ,
401+ body: " MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f " ) )
402+ try stored. write ( to: appGroupIdentityURL, atomically: true , encoding: . utf8)
403+ let storedAuth = #"{"version":1,"deviceId":"app-group-device-id","tokens":{}}"#
404+ try storedAuth. write ( to: appGroupAuthURL, atomically: true , encoding: . utf8)
405+ let appGroupBefore = try String ( contentsOf: appGroupIdentityURL, encoding: . utf8)
406+
407+ let migrationSource = DeviceIdentityPaths . appGroupMigrationSource (
408+ appGroupStateDirURL: appGroupURL,
409+ appGroupStateDirAvailable: false ,
410+ profile: . primary)
411+ let identity = DeviceIdentityStore . loadOrCreate (
412+ fileURL: legacyIdentityURL,
413+ migrationSource: migrationSource)
414+
415+ #expect( identity. deviceId == " 56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c " )
416+ #expect( FileManager . default. fileExists ( atPath: legacyIdentityURL. path) )
417+ let reloaded = DeviceIdentityStore . loadOrCreate ( fileURL: legacyIdentityURL)
418+ #expect( reloaded. deviceId == identity. deviceId)
419+ #expect( try String ( contentsOf: appGroupIdentityURL, encoding: . utf8) == appGroupBefore)
420+ #expect( try String ( contentsOf: legacyAuthURL, encoding: . utf8) == storedAuth)
421+ #expect( try String ( contentsOf: appGroupAuthURL, encoding: . utf8) == storedAuth)
422+ }
423+
424+ @Test
425+ func `does not clobber an existing auth store when migrating an app group identity`() throws {
426+ let tempDir = FileManager . default. temporaryDirectory
427+ . appendingPathComponent ( UUID ( ) . uuidString, isDirectory: true )
428+ defer { try ? FileManager . default. removeItem ( at: tempDir) }
429+ let appGroupIdentityDirURL = tempDir
430+ . appendingPathComponent ( " shared " , isDirectory: true )
431+ . appendingPathComponent ( " identity " , isDirectory: true )
432+ let legacyIdentityDirURL = tempDir
433+ . appendingPathComponent ( " legacy " , isDirectory: true )
434+ . appendingPathComponent ( " identity " , isDirectory: true )
435+ try FileManager . default. createDirectory ( at: appGroupIdentityDirURL, withIntermediateDirectories: true )
436+ try FileManager . default. createDirectory ( at: legacyIdentityDirURL, withIntermediateDirectories: true )
437+ let stored = try Self . identityJSON (
438+ publicKeyPem: Self . pem (
439+ label: " PUBLIC KEY " ,
440+ body: " MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg= " ) ,
441+ privateKeyPem: Self . pem (
442+ label: " PRIVATE KEY " ,
443+ body: " MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f " ) )
444+ try stored. write (
445+ to: appGroupIdentityDirURL. appendingPathComponent ( " device.json " , isDirectory: false ) ,
446+ atomically: true ,
447+ encoding: . utf8)
448+ try #"{"version":1,"deviceId":"app-group-device-id","tokens":{}}"# . write (
449+ to: appGroupIdentityDirURL. appendingPathComponent ( " device-auth.json " , isDirectory: false ) ,
450+ atomically: true ,
451+ encoding: . utf8)
452+ let legacyAuthURL = legacyIdentityDirURL. appendingPathComponent ( " device-auth.json " , isDirectory: false )
453+ let existingLegacyAuth = #"{"version":1,"deviceId":"legacy-device-id","tokens":{}}"#
454+ try existingLegacyAuth. write ( to: legacyAuthURL, atomically: true , encoding: . utf8)
455+
456+ let identity = DeviceIdentityStore . loadOrCreate (
457+ fileURL: legacyIdentityDirURL. appendingPathComponent ( " device.json " , isDirectory: false ) ,
458+ migrationSource: DeviceIdentityPaths . appGroupMigrationSource (
459+ appGroupStateDirURL: tempDir. appendingPathComponent ( " shared " , isDirectory: true ) ,
460+ appGroupStateDirAvailable: false ,
461+ profile: . primary) )
462+
463+ #expect( identity. deviceId == " 56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c " )
464+ #expect( try String ( contentsOf: legacyAuthURL, encoding: . utf8) == existingLegacyAuth)
465+ }
466+
467+ @Test
468+ func `keeps an existing legacy identity instead of migrating the app group copy`() throws {
469+ let tempDir = FileManager . default. temporaryDirectory
470+ . appendingPathComponent ( UUID ( ) . uuidString, isDirectory: true )
471+ defer { try ? FileManager . default. removeItem ( at: tempDir) }
472+ let appGroupURL = tempDir. appendingPathComponent ( " shared " , isDirectory: true )
473+ let appGroupIdentityURL = appGroupURL
474+ . appendingPathComponent ( " identity " , isDirectory: true )
475+ . appendingPathComponent ( " device.json " , isDirectory: false )
476+ let legacyIdentityURL = tempDir
477+ . appendingPathComponent ( " legacy " , isDirectory: true )
478+ . appendingPathComponent ( " identity " , isDirectory: true )
479+ . appendingPathComponent ( " device.json " , isDirectory: false )
480+ try FileManager . default. createDirectory (
481+ at: appGroupIdentityURL. deletingLastPathComponent ( ) ,
482+ withIntermediateDirectories: true )
483+ let stored = try Self . identityJSON (
484+ publicKeyPem: Self . pem (
485+ label: " PUBLIC KEY " ,
486+ body: " MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg= " ) ,
487+ privateKeyPem: Self . pem (
488+ label: " PRIVATE KEY " ,
489+ body: " MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f " ) )
490+ try stored. write ( to: appGroupIdentityURL, atomically: true , encoding: . utf8)
491+
492+ let existingLegacy = DeviceIdentityStore . loadOrCreate ( fileURL: legacyIdentityURL)
493+ let migrationSource = DeviceIdentityPaths . appGroupMigrationSource (
494+ appGroupStateDirURL: appGroupURL,
495+ appGroupStateDirAvailable: false ,
496+ profile: . primary)
497+ let identity = DeviceIdentityStore . loadOrCreate (
498+ fileURL: legacyIdentityURL,
499+ migrationSource: migrationSource)
500+
501+ #expect( identity. deviceId == existingLegacy. deviceId)
502+ #expect( identity. deviceId != " 56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c " )
503+ }
504+
505+ @Test
506+ func `provides no app group migration source when the entitlement is present`() {
507+ let appGroupURL = FileManager . default. temporaryDirectory
508+ . appendingPathComponent ( UUID ( ) . uuidString, isDirectory: true )
509+ #expect( DeviceIdentityPaths . appGroupMigrationSource (
510+ appGroupStateDirURL: appGroupURL,
511+ appGroupStateDirAvailable: true ,
512+ profile: . primary) == nil )
513+ }
514+
329515 private static func base64UrlDecode( _ value: String ) -> Data ? {
330516 let normalized = value
331517 . replacingOccurrences ( of: " - " , with: " + " )
0 commit comments