@@ -760,6 +760,97 @@ struct GatewayNodeSessionTests {
760760 await gateway. disconnect ( )
761761 }
762762
763+ @Test
764+ func `private lan bootstrap persists hando ff tokens for reconnect`() async throws {
765+ let tempDir = FileManager . default. temporaryDirectory
766+ . appendingPathComponent ( UUID ( ) . uuidString, isDirectory: true )
767+ try FileManager . default. createDirectory ( at: tempDir, withIntermediateDirectories: true )
768+ let previousStateDir = ProcessInfo . processInfo. environment [ " OPENCLAW_STATE_DIR " ]
769+ setenv ( " OPENCLAW_STATE_DIR " , tempDir. path, 1 )
770+ defer {
771+ if let previousStateDir {
772+ setenv ( " OPENCLAW_STATE_DIR " , previousStateDir, 1 )
773+ } else {
774+ unsetenv ( " OPENCLAW_STATE_DIR " )
775+ }
776+ try ? FileManager . default. removeItem ( at: tempDir)
777+ }
778+
779+ let identity = DeviceIdentityStore . loadOrCreate ( )
780+ let url = try #require( URL ( string: " ws://192.168.50.164:18889 " ) )
781+ let bootstrapSession = FakeGatewayWebSocketSession ( helloAuth: [
782+ " deviceToken " : " lan-node-token " ,
783+ " role " : " node " ,
784+ " scopes " : [ ] ,
785+ " deviceTokens " : [
786+ [
787+ " deviceToken " : " lan-operator-token " ,
788+ " role " : " operator " ,
789+ " scopes " : [
790+ " operator.approvals " ,
791+ " operator.read " ,
792+ ] ,
793+ ] ,
794+ ] ,
795+ ] )
796+ let gateway = GatewayNodeSession ( )
797+ let options = GatewayConnectOptions (
798+ role: " node " ,
799+ scopes: [ ] ,
800+ caps: [ ] ,
801+ commands: [ ] ,
802+ permissions: [ : ] ,
803+ clientId: " openclaw-ios-test " ,
804+ clientMode: " node " ,
805+ clientDisplayName: " iOS Test " ,
806+ includeDeviceIdentity: true )
807+
808+ try await gateway. connect (
809+ url: url,
810+ token: nil ,
811+ bootstrapToken: " fresh-bootstrap-token " ,
812+ password: nil ,
813+ connectOptions: options,
814+ sessionBox: WebSocketSessionBox ( session: bootstrapSession) ,
815+ onConnected: { } ,
816+ onDisconnected: { _ in } ,
817+ onInvoke: { req in
818+ BridgeInvokeResponse ( id: req. id, ok: true , payloadJSON: nil , error: nil )
819+ } )
820+ await gateway. disconnect ( )
821+
822+ let nodeEntry = try #require( DeviceAuthStore . loadToken ( deviceId: identity. deviceId, role: " node " ) )
823+ let operatorEntry = try #require( DeviceAuthStore . loadToken ( deviceId: identity. deviceId, role: " operator " ) )
824+ #expect( nodeEntry. token == " lan-node-token " )
825+ #expect( nodeEntry. scopes == [ ] )
826+ #expect( operatorEntry. token == " lan-operator-token " )
827+ #expect( operatorEntry. scopes == [
828+ " operator.approvals " ,
829+ " operator.read " ,
830+ ] )
831+
832+ let reconnectSession = FakeGatewayWebSocketSession ( )
833+ try await gateway. connect (
834+ url: url,
835+ token: nil ,
836+ bootstrapToken: nil ,
837+ password: nil ,
838+ connectOptions: options,
839+ sessionBox: WebSocketSessionBox ( session: reconnectSession) ,
840+ onConnected: { } ,
841+ onDisconnected: { _ in } ,
842+ onInvoke: { req in
843+ BridgeInvokeResponse ( id: req. id, ok: true , payloadJSON: nil , error: nil )
844+ } )
845+
846+ let reconnectAuth = try #require( reconnectSession. latestTask ( ) ? . latestConnectAuth ( ) )
847+ #expect( reconnectAuth [ " token " ] as? String == " lan-node-token " )
848+ #expect( reconnectAuth [ " bootstrapToken " ] == nil )
849+ #expect( reconnectAuth [ " deviceToken " ] == nil )
850+
851+ await gateway. disconnect ( )
852+ }
853+
763854 @Test
764855 func `normalize canvas host url preserves explicit secure canvas port`() throws {
765856 let normalized = try canonicalizeCanvasHostUrl (
0 commit comments